Compare commits
39
Commits
48ecfaab34
...
v1.0.0-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06be075b70 | ||
|
|
8406c77949 | ||
|
|
ded39aa158 | ||
|
|
0cdda43e6c | ||
|
|
f10440b7cc | ||
|
|
40e8ecaab4 | ||
|
|
147d58dd96 | ||
|
|
c7c4ea3340 | ||
|
|
8c05ec4378 | ||
|
|
872e4eb4a1 | ||
|
|
dfe8f3138f | ||
|
|
0d38aea12c | ||
|
|
c0f7cc29fd | ||
|
|
f14fe02011 | ||
|
|
573f7f986a | ||
|
|
373a2f828d | ||
|
|
267d5b12f2 | ||
|
|
e73beb20df | ||
|
|
c8efbd41d4 | ||
|
|
15377f792b | ||
|
|
f86ab6321d | ||
|
|
957de7f99f | ||
|
|
9199544999 | ||
|
|
128eb1b644 | ||
|
|
3de345fe6f | ||
|
|
a9d77e20d7 | ||
|
|
faf3da9a75 | ||
|
|
fa6a9ed4dc | ||
|
|
46fe7baed0 | ||
|
|
ea8cba7264 | ||
|
|
a8fc0c5d32 | ||
|
|
7963188c1e | ||
|
|
16ed356b68 | ||
|
|
fe5e0d4173 | ||
|
|
9ccaa8ce01 | ||
|
|
935e18c6f1 | ||
|
|
af91d8a723 | ||
|
|
7e779f7fce | ||
|
|
f703b72ce0 |
+83
-29
@@ -1,30 +1,84 @@
|
||||
# .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.
|
||||
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||
# `certs/server.key` still ship while this file reads as solved. Only
|
||||
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||
# into .gitignore, where `**/` is wrong.
|
||||
#
|
||||
# .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
|
||||
# stage of the Dockerfile.
|
||||
bin/
|
||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
||||
# needed. The tarball in 3p/ must stay in the context.
|
||||
static/js/alpine.min.js
|
||||
# The js-deps stage installs ESLint; a host copy would overwrite it at the
|
||||
# js-lint stage's `COPY . .`.
|
||||
node_modules/
|
||||
.env
|
||||
.env.*
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
tmp/
|
||||
temp/
|
||||
# Matching is case-sensitive, so secrets use character ranges rather
|
||||
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||
#
|
||||
# Extend with this repo's own host-built artifacts, written anchored:
|
||||
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||
# deletes the package directory from the context.
|
||||
|
||||
# .git is sent without its config. Without a VERSION build argument the
|
||||
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||
# does not need .git/config; that file can hold a credential, such as a
|
||||
# password in a remote URL or the token the CI checkout step stores there.
|
||||
# Each submodule keeps a config with the same exposure in its git directory
|
||||
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||
# its own .git directory when it keeps one.
|
||||
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||
# `**/.git/modules/**/config` also matches that segment's directory
|
||||
# under .git/modules/. Go's version stamping then fails the build;
|
||||
# nothing leaks. Name such a submodule without that segment:
|
||||
# `git submodule add --name`.
|
||||
**/.git/config
|
||||
**/.git/modules/**/config
|
||||
|
||||
# Agent scratch: one full checkout of the repo per in-flight agent.
|
||||
# Anchored because it occurs once where agents run at the repo root.
|
||||
# KNOWN GAP: a repo running agents in subdirectories still ships
|
||||
# `services/api/.claude/` and must add its own anchored entry.
|
||||
.claude
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Re-include a committed template with a negation if the
|
||||
# build needs one: `!docs/example.env`.
|
||||
**/*.[eE][nN][vV]
|
||||
**/.[eE][nN][vV].*
|
||||
**/.[eE][nN][vV][rR][cC]
|
||||
|
||||
# Private keys and the bundles carrying them. Public certificates
|
||||
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
|
||||
**/*.[pP][eE][mM]
|
||||
**/*.[kK][eE][yY]
|
||||
**/*.[pP]12
|
||||
**/*.[pP][fF][xX]
|
||||
**/[iI][dD]_[rR][sS][aA]
|
||||
**/[iI][dD]_[dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
**/[iI][dD]_[eE][dD]25519
|
||||
**/[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# Dependencies: restored inside the image, never copied in.
|
||||
**/node_modules
|
||||
|
||||
# OS metadata.
|
||||
**/.DS_Store
|
||||
**/Thumbs.db
|
||||
|
||||
# Editor state: never a build input, and it churns COPY.
|
||||
**/*.swp
|
||||
**/*.swo
|
||||
**/*~
|
||||
**/*.bak
|
||||
**/.idea
|
||||
**/.vscode
|
||||
**/*.sublime-*
|
||||
|
||||
# This repository's own host-built artifacts: the binary `make build`
|
||||
# writes, and the Alpine.js file `make assets` extracts from 3p/ (the
|
||||
# build extracts its own).
|
||||
/bin
|
||||
/static/js/alpine.min.js
|
||||
|
||||
# SQLite databases, which hold the session key and webhook payloads, at
|
||||
# any depth.
|
||||
**/*.db
|
||||
**/*.sqlite
|
||||
**/*.sqlite3
|
||||
|
||||
@@ -10,3 +10,6 @@ insert_final_newline = true
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
|
||||
[*.go]
|
||||
indent_style = tab
|
||||
|
||||
@@ -1,37 +1,9 @@
|
||||
name: check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
|
||||
on: [push]
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||
with:
|
||||
# The superseded-status step needs history to walk ancestors (it
|
||||
# aborts on a shallow clone).
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Mark superseded run statuses
|
||||
# Gitea cancels the in-flight run when another commit is pushed to the
|
||||
# same branch and records the cancellation as `failure`, so a commit
|
||||
# that was never tested reads as a test result. The script rewrites
|
||||
# those statuses to say what happened. See its header for why the
|
||||
# state stays `failure` and not `skipped`.
|
||||
env:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: script/ci-mark-superseded
|
||||
|
||||
- name: Fingerprint the build context
|
||||
# Writes the hash of the commit being checked into the context, which
|
||||
# invalidates the `COPY . .` layer of every check stage: a commit
|
||||
# that was never linted, format-checked, stylesheet-checked, tested
|
||||
# and built cannot report success from cache.
|
||||
run: git rev-parse HEAD > .ci-fingerprint
|
||||
|
||||
- name: Build Docker image (runs make fmt-check, golangci-lint, the stylesheet check, ESLint, make test, make build)
|
||||
run: script/cibuild
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# actions/checkout v4.2.2, 2026-02-22
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||
- run: script/cibuild
|
||||
|
||||
+50
-21
@@ -1,3 +1,53 @@
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
*.bak
|
||||
.idea/
|
||||
.vscode/
|
||||
*.sublime-*
|
||||
|
||||
# Agent scratch (worktrees of this repo, created and destroyed by
|
||||
# in-flight tooling). Unanchored: .gitignore patterns already match at
|
||||
# every depth, so no prefix is wanted here. This is not a .dockerignore
|
||||
# entry and must not be given a `**/` prefix on the way into one.
|
||||
.claude/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# Secrets. Unanchored like every entry above, so each matches at every
|
||||
# depth. Matching is case-sensitive on Linux, so names use character
|
||||
# ranges rather than a lowercase form that misses `Server.Key`.
|
||||
|
||||
# Environment files. `*.env` covers bare `.env` and the `prod.env`
|
||||
# convention. Only the templates `example.env` and `sample.env` are
|
||||
# re-included below. A repository that commits any other template adds
|
||||
# its own negation after these lines, for example `!.env.example`.
|
||||
*.[eE][nN][vV]
|
||||
.[eE][nN][vV].*
|
||||
.[eE][nN][vV][rR][cC]
|
||||
!example.env
|
||||
!sample.env
|
||||
|
||||
# Private keys and the bundles carrying them.
|
||||
*.[pP][eE][mM]
|
||||
*.[kK][eE][yY]
|
||||
*.[pP]12
|
||||
*.[pP][fF][xX]
|
||||
[iI][dD]_[rR][sS][aA]
|
||||
[iI][dD]_[dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]
|
||||
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
|
||||
[iI][dD]_[eE][dD]25519
|
||||
[iI][dD]_[eE][dD]25519_[sS][kK]
|
||||
|
||||
# This repository's own entries, after the shared content above.
|
||||
|
||||
# Binaries
|
||||
*.exe
|
||||
*.dll
|
||||
@@ -15,24 +65,6 @@ bin/
|
||||
# Go vendor directory
|
||||
vendor/
|
||||
|
||||
# ESLint and its dependencies, installed from yarn.lock
|
||||
node_modules/
|
||||
|
||||
# IDE specific files
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.vscode/
|
||||
|
||||
# OS specific files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Environment and config files
|
||||
.env
|
||||
.env.local
|
||||
|
||||
# Data directory (SQLite databases)
|
||||
data/
|
||||
*.db
|
||||
@@ -46,9 +78,6 @@ data/
|
||||
tmp/
|
||||
temp/
|
||||
|
||||
# CI cache barrier, written into the build context by the check workflow
|
||||
.ci-fingerprint
|
||||
|
||||
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||
# what is committed.
|
||||
/static/js/alpine.min.js
|
||||
|
||||
+73
-2
@@ -10,14 +10,21 @@ run:
|
||||
|
||||
linters:
|
||||
default: all
|
||||
enable:
|
||||
# Successor to the deprecated gomodguard. Named explicitly, rather than
|
||||
# left to `default: all`, because it carries the module policy below.
|
||||
- gomodguard_v2
|
||||
disable:
|
||||
# Genuinely incompatible with project patterns
|
||||
- exhaustruct # Requires all struct fields
|
||||
- depguard # Dependency allow/block lists
|
||||
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
|
||||
- godot # Requires comments to end with periods
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- wrapcheck # Too verbose for internal packages
|
||||
- varnamelen # Short names like db, id are idiomatic Go
|
||||
# Deprecated: the warning is attached to the old name, so it is
|
||||
# silenced by disabling that name, not by enabling the successor.
|
||||
- wsl # Deprecated, replaced by wsl_v5
|
||||
- gomodguard # Deprecated, replaced by gomodguard_v2
|
||||
settings:
|
||||
lll:
|
||||
line-length: 88
|
||||
@@ -28,6 +35,70 @@ linters:
|
||||
max-complexity: 15
|
||||
dupl:
|
||||
threshold: 100
|
||||
depguard:
|
||||
# Test-support code must not be compiled into the shipped binary. A
|
||||
# test-support package exists to hand a test privileges the program
|
||||
# itself must never have, so a file that is not a test must not import
|
||||
# one. Test files, and the files inside a package whose directory name
|
||||
# ends in `test`, are where that code belongs, and are exempt.
|
||||
#
|
||||
# The deny list below is the one part of this file a repository is
|
||||
# expected to extend, and the only part it may. depguard matches an
|
||||
# import path against a list of prefixes, so it cannot be told "any path
|
||||
# whose last segment ends in test"; a repository's own test-support
|
||||
# packages have to be named here one at a time, by full import path,
|
||||
# under a module path that differs from repository to repository. Add
|
||||
# them; change nothing else.
|
||||
rules:
|
||||
test-support:
|
||||
list-mode: lax
|
||||
files:
|
||||
- "$all"
|
||||
- "!$test"
|
||||
- "!**/*test/**"
|
||||
deny:
|
||||
- pkg: net/http/httptest
|
||||
desc: >-
|
||||
Test-support code belongs in test files and in packages whose
|
||||
directory name ends in test, not in the shipped binary.
|
||||
- pkg: sneak.berlin/go/webhooker/internal/config/configtest
|
||||
desc: test support; a file that is not a test must not import it
|
||||
- pkg: sneak.berlin/go/webhooker/internal/database/databasetest
|
||||
desc: test support; a file that is not a test must not import it
|
||||
- pkg: sneak.berlin/go/webhooker/internal/middleware/middlewaretest
|
||||
desc: test support; a file that is not a test must not import it
|
||||
# Only decisions already recorded in the Go package defaults are
|
||||
# listed here. Every entry matches the module path exactly.
|
||||
gomodguard_v2:
|
||||
blocked:
|
||||
- module: github.com/rs/zerolog
|
||||
recommendations:
|
||||
- log/slog
|
||||
reason: "Structured logging is stdlib log/slog."
|
||||
# One entry per pre-fork module path, because the later releases
|
||||
# are separate paths. A prefix match would be shorter but would
|
||||
# also reach github.com/go-redis/redismock, the test double for
|
||||
# the successor these entries recommend.
|
||||
- module: github.com/go-redis/redis
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v7
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/go-redis/redis/v8
|
||||
recommendations:
|
||||
- github.com/redis/go-redis/v9
|
||||
reason: "Pre-fork module; use the maintained go-redis v9."
|
||||
- module: github.com/sergi/go-diff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "No unified diff output; use go-udiff."
|
||||
- module: github.com/hexops/gotextdiff
|
||||
recommendations:
|
||||
- github.com/aymanbagabas/go-udiff
|
||||
reason: "Unmaintained fork; use go-udiff."
|
||||
|
||||
issues:
|
||||
max-issues-per-linter: 0
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
node_modules/
|
||||
yarn.lock
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"tabWidth": 4,
|
||||
"proseWrap": "always"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
|
||||
# ESLint and prettier from node_modules/.bin.
|
||||
nodeLinker: node-modules
|
||||
+153
-76
@@ -1,34 +1,3 @@
|
||||
# Lint stage
|
||||
# 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 lint
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy go mod files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
||||
# holds the hash of the commit being checked (see
|
||||
# .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
|
||||
# .dockerignore.
|
||||
COPY . .
|
||||
|
||||
# Run formatting check and linter. golangci-lint is invoked directly rather
|
||||
# than through `make lint`: this stage is already the pinned linter image, and
|
||||
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here
|
||||
# would need a docker daemon inside the build. Keep these steps in step with
|
||||
# Dockerfile.lint, including --network=none (see its header for why).
|
||||
RUN make fmt-check
|
||||
RUN script/assets
|
||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||
|
||||
# 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.
|
||||
@@ -66,34 +35,146 @@ RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
||||
}
|
||||
|
||||
# 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
|
||||
# stays cached until those two files change. 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.
|
||||
# node:24.21.0-alpine (LTS, with yarn 1.22.22), 2026-09-18
|
||||
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
|
||||
# prettier for the Markdown stages below. The lint phase below runs js-lint.
|
||||
#
|
||||
# The image's own corepack runs the yarn that package.json's packageManager
|
||||
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
|
||||
# hash there. The image also ships yarn 1, which `corepack enable yarn`
|
||||
# replaces.
|
||||
# node:24.21.0-alpine (LTS), 2026-09-18
|
||||
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
|
||||
WORKDIR /src
|
||||
COPY package.json yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --ignore-scripts
|
||||
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
|
||||
|
||||
# Build stage
|
||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||
# 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 \;
|
||||
|
||||
# Depend on the lint, stylesheet check and JavaScript lint stages passing
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=css-check /out/tailwind.css /dev/null
|
||||
FROM scratch AS markdown-output
|
||||
COPY --from=markdown /out /
|
||||
|
||||
FROM js-deps AS markdown-check
|
||||
COPY . .
|
||||
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
|
||||
|
||||
# Lint phase: the Go formatting check and golangci-lint over the Go code,
|
||||
# and ESLint over static/js/ through the copy from js-lint at the end.
|
||||
# `make lint` (script/lint) builds this stage alone; the build stage below
|
||||
# depends on it.
|
||||
#
|
||||
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Copy go mod files first for better layer caching
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# gofmt and golangci-lint are invoked directly rather than through `make
|
||||
# fmt-check` and `make lint`, which are themselves docker builds and would
|
||||
# need a docker daemon inside this one. The Markdown half of `make
|
||||
# fmt-check` is the markdown-check stage above.
|
||||
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
|
||||
|
||||
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
|
||||
# it the static package does not compile and cannot be linted.
|
||||
RUN script/assets
|
||||
|
||||
# The golangci-lint steps run with --network=none. `golangci-lint config
|
||||
# verify` is documented as fetching its JSON schema over HTTPS; this pinned
|
||||
# image resolves the schema without any network, and --network=none enforces
|
||||
# that. It also proves no linter reaches out at analysis time.
|
||||
#
|
||||
# `run` silently ignores config keys it does not recognize, so a typo would
|
||||
# disable a setting without a word. `config verify` is what catches that.
|
||||
RUN --network=none golangci-lint config verify --config .golangci.yml
|
||||
# --build-tags browser also lints the browser test, which is built only with
|
||||
# that tag (make test-browser).
|
||||
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||
|
||||
# Nothing is wanted from js-lint; the copy is what makes this phase run it.
|
||||
COPY --from=js-lint /src/yarn.lock /dev/null
|
||||
|
||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
||||
# suite executes. git is what script/version derives the version with.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go image
|
||||
# ships and the alpine one does not. `make test` (script/test) builds this
|
||||
# stage alone; the build stage below depends on it.
|
||||
#
|
||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
# static/static.go embeds the Alpine.js file this extracts from 3p/.
|
||||
RUN script/assets
|
||||
|
||||
# -timeout applies to each package on its own, so 90s has only to clear the
|
||||
# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most
|
||||
# four test binaries build or run at once, each with at most eight parallel
|
||||
# tests. Under -race every test binary and every link costs a few hundred MB,
|
||||
# so the defaults (one per core) add up to several GB on a many-core host.
|
||||
#
|
||||
# The first run has no -v: go test then prints one result line per package,
|
||||
# with its coverage, and for a package that fails, everything its tests
|
||||
# wrote. Verbose output from the whole suite passes the 2 MiB at which the
|
||||
# Docker build cuts off a step's log, so on a failure only the tests that
|
||||
# failed run again, with -v. go test reports a failed test as a line starting
|
||||
# "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with
|
||||
# its parent) and a failed package as "FAIL<tab>package/path<tab>...". A
|
||||
# failure that names no test, such as a build error or a timeout, is already
|
||||
# shown in full, so there is nothing to rerun. The step fails after the rerun
|
||||
# whatever its result: the first run already showed the suite is broken.
|
||||
#
|
||||
# TMPDIR, where the tests keep their SQLite databases, is a tmpfs: SQLite
|
||||
# waits for the disk at every commit, and on a busy host that waiting was
|
||||
# about 40% of the slowest package's run time. GOTMPDIR keeps go's own
|
||||
# build files, the test binaries among them, on disk.
|
||||
#
|
||||
# bash with pipefail, so that the first run's status is go test's, not tee's.
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
|
||||
export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \
|
||||
go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \
|
||||
tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \
|
||||
packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \
|
||||
if [ -n "$tests" ]; then \
|
||||
echo "--- Rerunning the failed tests with -v for details ---"; \
|
||||
go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \
|
||||
fi; \
|
||||
exit 1
|
||||
|
||||
# Build stage
|
||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||
# The image ships git and make, which the version step below uses.
|
||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||
|
||||
# Nothing is wanted from the lint and test phases or from the stylesheet and
|
||||
# Markdown checks; the copies are what make BuildKit build them first, so
|
||||
# this stage cannot run unless they all passed.
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
COPY --from=css-check /out/tailwind.css /dev/null
|
||||
COPY --from=markdown-check /src/yarn.lock /dev/null
|
||||
|
||||
# A build context sent as a tar archive keeps its files' owners, and git
|
||||
# refuses to read a checkout owned by another user. Trust this one
|
||||
@@ -106,44 +187,31 @@ WORKDIR /build
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Copy source code, including the .ci-fingerprint cache barrier described in
|
||||
# the lint stage above.
|
||||
COPY . .
|
||||
|
||||
# Run tests and build. Both first run script/assets, which extracts Alpine.js
|
||||
# from its tarball in 3p/.
|
||||
RUN make test
|
||||
|
||||
# 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; \
|
||||
# A context that carries .git must not stamp an empty version, "dev" or
|
||||
# "unknown": that means git is missing here or could not read the
|
||||
# checkout, and the image could not be traced back to its commit.
|
||||
RUN version="$(make version VERSION="$VERSION")"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$version" in ""|dev|unknown) \
|
||||
echo "version is '$version' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
fi
|
||||
|
||||
# Builds through the Makefile's build target, which runs script/assets
|
||||
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds
|
||||
# with cgo off, so the binary is statically linked and runs on the Alpine
|
||||
# runtime image below as it is.
|
||||
RUN make build VERSION="$VERSION"
|
||||
|
||||
# Rebuild with static linking for Alpine runtime.
|
||||
# make build already verified compilation.
|
||||
# The CGO binary from `make build` is dynamically linked against glibc,
|
||||
# which doesn't exist on Alpine (musl). Rebuild with static linking so
|
||||
# the binary runs on Alpine without glibc.
|
||||
#
|
||||
# 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
|
||||
# alpine:3.21, 2026-03-17
|
||||
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
|
||||
@@ -187,8 +255,17 @@ EXPOSE 8080
|
||||
# port.
|
||||
ENV BIND_ADDRESS=0.0.0.0
|
||||
|
||||
# The probe requests the container's own address, not loopback, so a
|
||||
# listener that answers only on loopback (an empty or loopback
|
||||
# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the
|
||||
# container's IPv4 address, which 0.0.0.0 and :: both answer. On an
|
||||
# IPv6-only network it prints none, and the probe requests the IPv6
|
||||
# address /etc/hosts lists under the container's hostname instead, which
|
||||
# only :: answers.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
|
||||
CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \
|
||||
[ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \
|
||||
wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["/app/webhooker"]
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ 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.
|
||||
# the test phase of Dockerfile.
|
||||
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
|
||||
|
||||
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
||||
|
||||
@@ -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 ./...
|
||||
@@ -16,11 +16,6 @@ VERSION ?= $(shell script/version)
|
||||
# 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:
|
||||
@script/bootstrap
|
||||
|
||||
@@ -48,8 +43,12 @@ fmt-check:
|
||||
check:
|
||||
@script/check
|
||||
|
||||
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
|
||||
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
|
||||
# debug information, and on Linux, the image's included, it is statically
|
||||
# linked.
|
||||
build: assets
|
||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
|
||||
|
||||
run: build
|
||||
./bin/webhooker
|
||||
|
||||
+353
-90
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: Repository Policies
|
||||
last_modified: 2026-08-07
|
||||
last_modified: 2026-10-04
|
||||
---
|
||||
|
||||
This document covers repository structure, tooling, and workflow standards. Code
|
||||
@@ -60,17 +60,28 @@ style conventions are in separate documents:
|
||||
prerequisite since nvm requires bash. yarn is then pinned via
|
||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
||||
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
|
||||
scripts are our own extensions to the standard: `script/check` runs
|
||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
||||
what the git pre-commit hook runs, and it calls `script/check`;
|
||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
||||
target shims to it); and `script/projectname` (literally that filename) simply
|
||||
outputs the project's name. Scripts that need the name call
|
||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
||||
`script/precommit`, not in the hook itself. Model scripts are at
|
||||
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||
pristine checkout with nothing installed the run dies there, after the
|
||||
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||
source nvm for the pinned node version before invoking it, exactly as
|
||||
`script/bootstrap`'s own install step does. A runner carrying nothing but
|
||||
docker and git then gets through `script/check`. Four further scripts are our
|
||||
own extensions to the standard: `script/check` runs `script/test`,
|
||||
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
|
||||
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
|
||||
installs the git pre-commit hook (the `make hooks` target shims to it); and
|
||||
`script/projectname` (literally that filename) simply outputs the project's
|
||||
name. Scripts that need the name call `script/projectname` — e.g.
|
||||
`script/docker` assembles its image tag from it — so those scripts stay
|
||||
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
|
||||
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
|
||||
the hook itself. Model scripts are at
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||
must document the provided scripts in an **Entrypoints** section (see the
|
||||
README requirements below).
|
||||
@@ -89,87 +100,198 @@ style conventions are in separate documents:
|
||||
contributor should be able to understand the entire development workflow by
|
||||
reading the Makefile.
|
||||
|
||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
||||
as a build step so the build fails if the branch is not green. For non-server
|
||||
repos, the Dockerfile should bring up a development environment and run
|
||||
`make check`. For server repos, `make check` should run as an early build
|
||||
stage before the final image is assembled. Dockerfiles install development
|
||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
||||
layer stays cached until dependencies change.
|
||||
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||
image cannot be built unless they pass. For non-server repos the final stage
|
||||
brings up a development environment; for server repos it is the runtime image.
|
||||
The gate phases and the build stage start from their pinned base images and
|
||||
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||
repo's own `Dockerfile` does for its yarn packages. The development
|
||||
environment stage installs development prerequisites by running
|
||||
`script/bootstrap` rather than duplicating its installs inline. A stage that
|
||||
runs `script/bootstrap` COPYs `script/` and the dependency manifests
|
||||
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
|
||||
|
||||
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
|
||||
repos use a multistage build where linting runs in an independent stage based
|
||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
||||
`make fmt-check` and `make lint` before the full build begins. The build stage
|
||||
then declares an explicit dependency on the lint stage via
|
||||
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
|
||||
linting before proceeding to compilation and tests. This ensures lint failures
|
||||
surface in seconds rather than minutes, without blocking on dependency
|
||||
download or compilation in the build stage.
|
||||
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||
and nothing else:
|
||||
|
||||
The standard pattern for a Go repo Dockerfile is:
|
||||
```sh
|
||||
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
|
||||
docker build --no-cache --target test -t "$(script/projectname)-test" .
|
||||
```
|
||||
|
||||
**A stage that is not the last one in the file is built only when the final
|
||||
stage's chain depends on it, or when `--target` names it.** That is why the
|
||||
two gates are always invoked by name here, and why the final stage carries a
|
||||
`COPY --from=` of a harmless file from each of them: without that edge a
|
||||
plain `docker build .` builds the last stage alone and exits 0 having linted
|
||||
and tested nothing.
|
||||
|
||||
**Every `docker build` in `script/` is tagged**, here and in
|
||||
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
|
||||
image behind on every invocation, on every developer host and every CI
|
||||
runner; a tagged one replaces the previous image.
|
||||
|
||||
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
|
||||
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
|
||||
themselves a `docker build` and would recurse into a daemon that does not
|
||||
exist in a build step. Formatting is the exception and stays on the host:
|
||||
`script/fmt` writes the working tree, and `script/fmt-check` is its
|
||||
read-only twin.
|
||||
|
||||
**No lint verdict may come from a host invocation of the linter.** On a
|
||||
shared host golangci-lint reads a result cache keyed on file content rather
|
||||
than location, so a second checkout of the same content is served the first
|
||||
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
|
||||
exit non-zero with `parallel golangci-lint is running` — a status a caller
|
||||
cannot tell from real findings. Both have produced wrong verdicts in this
|
||||
org, in both directions. A container has its own cache, its own `TMPDIR` and
|
||||
a digest-pinned binary, so neither is reachable.
|
||||
|
||||
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
|
||||
a `COPY` layer only when the copied content changes, so on an unchanged tree
|
||||
the check `RUN` is served from cache, nothing executes, and the build still
|
||||
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
|
||||
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
|
||||
four, and there is no fifth — `script/check` runs the two gate phases and
|
||||
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
|
||||
not evidence that anything ran: a sub-second build reporting success is a
|
||||
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
|
||||
and friends destroy a build cache shared with every other build on the host.
|
||||
When a check is added or changed, prove it works by planting a defect it must
|
||||
catch and watching the run fail on it, then revert the defect. A green run
|
||||
alone shows neither that the check ran nor that it covers what it should.
|
||||
|
||||
- **The gate phases are separate stages, and the build stage depends on both.**
|
||||
The lint phase is based on the `golangci/golangci-lint` image (pinned by
|
||||
hash), so lint failures surface in seconds rather than after a full compile,
|
||||
and the test phase is based on the Debian Go image. The canonical Go repo
|
||||
`Dockerfile`:
|
||||
|
||||
```dockerfile
|
||||
# Lint stage — fast feedback on formatting and lint issues
|
||||
# Lint phase
|
||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||
FROM golangci/golangci-lint@sha256:... AS lint
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make fmt-check
|
||||
RUN make lint
|
||||
RUN golangci-lint run --config .golangci.yml ./...
|
||||
|
||||
# Build stage
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||
# image ships and the alpine one does not.
|
||||
# golang:1.x, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS test
|
||||
WORKDIR /src
|
||||
|
||||
# Force BuildKit to run the lint stage before proceeding
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN make test
|
||||
RUN go test -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 90s -race -v ./...; exit 1; }
|
||||
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
# Build stage. Nothing is wanted from either phase above; the copies
|
||||
# are what make BuildKit build them first, so this stage cannot run
|
||||
# unless lint and test passed.
|
||||
# golang:1.x-alpine, YYYY-MM-DD
|
||||
FROM golang@sha256:... AS builder
|
||||
COPY --from=lint /src/go.sum /dev/null
|
||||
COPY --from=test /src/go.sum /dev/null
|
||||
RUN apk add --no-cache git
|
||||
# A tar-stream context keeps the sender's file owners, which git refuses.
|
||||
RUN git config --system --add safe.directory /src
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
|
||||
# Runtime stage
|
||||
# The VERSION build arg when one is given, otherwise
|
||||
# `git describe --tags --always` on the .git in the build context. With
|
||||
# .git present, a version that is still empty, dev or unknown fails the
|
||||
# build: git is missing or could not read the checkout.
|
||||
ARG VERSION
|
||||
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
|
||||
if [ -e .git ]; then \
|
||||
case "$VERSION" in ""|dev|unknown) \
|
||||
echo "version is '$VERSION' although .git is present" >&2; \
|
||||
exit 1 ;; \
|
||||
esac; \
|
||||
fi; \
|
||||
CGO_ENABLED=0 go build -trimpath \
|
||||
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||
-o /app ./cmd/app/
|
||||
|
||||
# Runtime stage, and the last one
|
||||
FROM alpine@sha256:...
|
||||
COPY --from=builder /app /usr/local/bin/app
|
||||
ENTRYPOINT ["app"]
|
||||
```
|
||||
|
||||
Key points:
|
||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
||||
includes both Go and the linter), so there is no need to install the
|
||||
linter separately.
|
||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
||||
this line, the build stage would not wait for lint to finish and a lint
|
||||
failure might not fail the overall build.
|
||||
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||
both Go and the linter), so nothing needs installing.
|
||||
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||
and a stage nothing depends on is not built at all, so without these two
|
||||
lines a red gate would not fail the build.
|
||||
- Keep the runtime stage last, and if you add a stage after it, give it the
|
||||
same two copies. A plain `docker build .` builds the last stage's chain
|
||||
and nothing else.
|
||||
- If the project uses `//go:embed` directives that reference build artifacts
|
||||
(e.g. a web frontend compiled in a separate stage), the lint stage must
|
||||
(e.g. a web frontend compiled in a separate stage), the lint phase must
|
||||
create placeholder files so the embed directives resolve. Example:
|
||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||
The lint stage should not depend on the actual build output — it exists to
|
||||
fail fast.
|
||||
- If the project requires CGO or system libraries for linting (e.g.
|
||||
`vips-dev`), install them in the lint stage with `apk add`.
|
||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
||||
build stage, not the lint stage, because they may require compiled
|
||||
artifacts or heavier dependencies.
|
||||
- If the project requires CGO or system libraries for linting, install them
|
||||
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||
has no `apk`, so install with `apt-get` under the Debian package name
|
||||
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||
lists in the same `RUN`, so the layer does not keep them:
|
||||
|
||||
```dockerfile
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends libvips-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
- `.dockerignore` lets `.git` into the build context. It keeps out every git
|
||||
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
|
||||
repository's own, each submodule's under `.git/modules/`, and that of a
|
||||
submodule keeping its own `.git` directory. `git describe` does not need
|
||||
them, and each can hold a credential: a password in a remote URL, or the
|
||||
token the CI checkout step stores there. A submodule whose name has a
|
||||
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
|
||||
git directory to `**/.git/modules/**/config`, and Go's version stamping
|
||||
then fails the build: give it a name without that segment
|
||||
(`git submodule add --name`). The stage that compiles has `git` (the
|
||||
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
|
||||
takes the version from the `VERSION` build argument when one is given,
|
||||
otherwise from `git describe --tags --always`. That gives the tag on a
|
||||
tagged commit; on a later commit, the tag, the number of commits since it
|
||||
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
|
||||
tag is reachable. The stage that compiles also marks its working directory
|
||||
safe for git (`git config --system --add safe.directory /src`): a context
|
||||
sent as a tar stream keeps the sender's file owners, and git refuses a
|
||||
checkout owned by another user, so the version would come out empty.
|
||||
`ARG VERSION` has no default, and the build fails if the context carries
|
||||
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
|
||||
`docker build .` with no build arguments must succeed; a Dockerfile that
|
||||
refuses an empty build argument drops that refusal and keeps the argument.
|
||||
|
||||
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
||||
Dockerfile already runs `make check`, a successful build implies all checks
|
||||
pass.
|
||||
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||
successful run means every check passed; a bare `docker build .` does not
|
||||
carry the same guarantee, because its gate phases may come from the cache. The
|
||||
image build is uncached and so runs the gate phases a second time. That is the
|
||||
price of the rule above, and it is worth paying: the image that ships is built
|
||||
from a run of its own gates rather than from a cache entry. A separate
|
||||
workflow limited to `main` by a `branches` list under `on: push` cannot be
|
||||
checked by review: to try a change to it, add the feature branch to that list
|
||||
and push, then remove the branch from the list again before merging. Keep any
|
||||
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
|
||||
from the feature branch publishes nothing.
|
||||
|
||||
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
||||
@@ -193,15 +315,17 @@ style conventions are in separate documents:
|
||||
suite that exceeds it fails. Under 20 seconds is the target. A suite between
|
||||
20 and 60 seconds is still green, but the overage must be filed as an
|
||||
improvement bug against that repo. Add a 90-second timeout to the test
|
||||
invocation 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.
|
||||
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||
one.
|
||||
|
||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
||||
show full output. This keeps CI logs and `docker build` output clean on
|
||||
success (just package/suite summaries) while providing full diagnostic detail
|
||||
on failure (every test case, every assertion). The general shell pattern:
|
||||
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||
on success (just package/suite summaries) while providing full diagnostic
|
||||
detail on failure (every test case, every assertion). The command lives in the
|
||||
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
|
||||
Makefile form below is the same pattern for any repo-local invocation:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@@ -214,11 +338,26 @@ style conventions are in separate documents:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
@go test -timeout 90s -race -cover ./... || \
|
||||
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||
{ echo "--- Rerunning with -v for details ---"; \
|
||||
go test -timeout 90s -race -v ./...; exit 1; }
|
||||
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||
```
|
||||
|
||||
`-count=1` is required on both invocations: it defeats Go's test _result_
|
||||
cache, so neither run can report a stored pass in place of running the
|
||||
tests. It leaves the build cache alone, so it costs the runtime of the suite
|
||||
and no recompilation.
|
||||
|
||||
That cache is Go's own, separate from Docker's layer cache. Go stores a
|
||||
passing result in its cache directory (`GOCACHE`), and when the same tests
|
||||
run again on unchanged code it prints that result, marked `(cached)`,
|
||||
without running them. That matters on a developer's machine, where this
|
||||
target runs and the directory lasts from one run to the next. The `test`
|
||||
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
|
||||
result for this repo's tests and nothing before its `go test` step runs a
|
||||
test, so there is nothing to replay. `--no-cache` (above) is what makes that
|
||||
step run on an unchanged tree.
|
||||
|
||||
Python example:
|
||||
|
||||
```makefile
|
||||
@@ -244,10 +383,84 @@ style conventions are in separate documents:
|
||||
must be in `.gitignore`. No exceptions.
|
||||
|
||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
||||
Fetch the standard `.gitignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
||||
a new repo.
|
||||
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||
setting up a new repo. These patterns are written to `.gitignore`'s own
|
||||
semantics, in which an unanchored pattern already matches at every depth; they
|
||||
are not a `.dockerignore` and must not be transplanted into one unmodified.
|
||||
|
||||
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
|
||||
across unmodified leaves secrets in the build context.** Docker matches with
|
||||
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
|
||||
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
|
||||
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
|
||||
therefore excludes only the copies at the repository root, while `config/.env`
|
||||
and `certs/server.key` still reach the context and can land in an image layer
|
||||
— which is more dangerous than a short file with no secret patterns at all,
|
||||
because it reads as solved and stops anyone looking. Give every
|
||||
depth-independent pattern the `**/` prefix and leave only genuinely
|
||||
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
|
||||
binary, written `/myapp` and never `**/myapp`, which would also match
|
||||
`cmd/myapp/` and delete the package directory from the context. Matching is
|
||||
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
|
||||
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
|
||||
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
|
||||
also catches something the build needs, re-include it with a negation
|
||||
(`!docs/example.env`); deleting the pattern reopens the exposure for every
|
||||
other file it covers. Fetch the standard `.dockerignore` from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
|
||||
it with the repo's own artifacts.
|
||||
|
||||
- **In-repo agent scratch belongs in both files, written to each file's own
|
||||
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
|
||||
additional checkout of the repo — so under `COPY . .` the build context
|
||||
inflates by a multiple of the repo and another session's unreviewed work can
|
||||
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
|
||||
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
|
||||
prefix, because the prefixed form would also delete any nested directory of
|
||||
that name from the build. Anchoring carries a known gap that the canonical
|
||||
`.dockerignore` states in its own comment, since consuming repos receive the
|
||||
file and not the tracker: the directory is created in the agent's working
|
||||
directory, so a repo running agents in subdirectories still ships
|
||||
`services/api/.claude/` and must add its own anchored entry there.
|
||||
|
||||
- **A plain `docker build .` of a clone stamps the version that
|
||||
`git describe --tags --always` gives**, derived from the `.git` in the build
|
||||
context as the canonical `Dockerfile` above shows. Without its failure check,
|
||||
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
|
||||
and the build would still exit 0. `script/docker` and `script/cibuild` pass
|
||||
the version they compute on the host; it takes precedence. They do this
|
||||
byte-identically across repos:
|
||||
|
||||
```sh
|
||||
# Own line: a failing command substitution inside an argument does not
|
||||
# trip `set -e`, so the inline form degrades to an empty constant.
|
||||
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
|
||||
[ -n "$version" ] || version="unknown"
|
||||
docker build --no-cache \
|
||||
--build-arg VERSION="$version" \
|
||||
-t "$(script/projectname)" .
|
||||
```
|
||||
|
||||
`--always` makes an untagged repo yield an abbreviated commit hash rather
|
||||
than failing, and the `[ -n "$version" ]` line is the single place the
|
||||
fallback is applied — a live check that fires on a build from an export with
|
||||
no `.git` and on a repository with no commits yet. Do not fold it into the
|
||||
substitution as `|| echo unknown`, which makes the guard unreachable. The
|
||||
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
|
||||
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
|
||||
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
|
||||
the scripts stay byte-identical. One consequence for CI: the standard
|
||||
checkout action clones shallow and fetches no tags, so a repo that embeds a
|
||||
tag-derived version must set `fetch-depth: 0` on its checkout step.
|
||||
|
||||
- **Verify `.dockerignore` by enumerating the image, not by reading the
|
||||
patterns.** Plant files at the root _and_ at least two directories deep, build
|
||||
a probe image that does `COPY . .`, and list what actually landed
|
||||
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
|
||||
size is not a substitute: a nested secret is a few bytes, and BuildKit
|
||||
transfers only the delta from the previous build.
|
||||
|
||||
- **No build artifacts in version control.** Code-derived data (compiled
|
||||
bundles, minified output, generated assets) must never be committed to the
|
||||
@@ -263,12 +476,56 @@ style conventions are in separate documents:
|
||||
- Make all changes on a feature branch. You can do whatever you want on a
|
||||
feature branch.
|
||||
|
||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
||||
manually by the user. Fetch from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
|
||||
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`.
|
||||
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||
_NEVER_ be modified by an agent: fetch it from
|
||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||
reach consuming repos by re-vendoring; an agent may open a PR against
|
||||
canonical, which only the user merges. One list is exempt from byte-identity,
|
||||
because it cannot be written once for every repo: the `deny` list of the
|
||||
`test-support` depguard rule, where a repo names its own test-support packages
|
||||
by full import path. A repo adds entries there and changes nothing else, and a
|
||||
re-vendor carries its entries forward. The canonical golangci-lint version is
|
||||
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
|
||||
image
|
||||
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
|
||||
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
|
||||
directive must not name a newer Go minor version than the one golangci-lint
|
||||
was built with, or golangci-lint refuses to lint it: this release lints
|
||||
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
|
||||
installs golangci-lint on the host. A repo sets the lint phase digest to the
|
||||
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
|
||||
the two prompted the change: the canonical copy can name linters that an older
|
||||
golangci-lint rejects, and a newer golangci-lint can add linters that
|
||||
`default: all` switches on until the canonical copy disables them.
|
||||
|
||||
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
|
||||
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
|
||||
`PATH` only, so on an already-provisioned machine the pin is inert and a
|
||||
version bump is a silent no-op — while the Dockerfile, installing into a clean
|
||||
image, gets the pinned version, so a local `make check` and `make docker` can
|
||||
disagree about what the tool even is. The canonical form:
|
||||
- compares the installed version against the pin over the **whole** version
|
||||
token; a parser that stops at the first `-` reports `2.12.2` for a host
|
||||
running `2.12.2-rc1` and skips the install;
|
||||
- treats absent, non-zero, empty or unrecognised `--version` output as a
|
||||
mismatch, so the failure direction is a redundant install and never a
|
||||
skipped one;
|
||||
- after installing, re-resolves the binary the way callers do — `hash -r`,
|
||||
then through `PATH`, not through the directory the installer wrote to —
|
||||
and fails naming the resolved path, since an install that a shadowing
|
||||
binary hides succeeds while changing nothing any caller sees;
|
||||
- is actually called, and prints the version on both success paths: a
|
||||
function defined and never invoked has the same exit status and the same
|
||||
empty output as one that worked.
|
||||
|
||||
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
|
||||
|
||||
A Go tool a repo needs on the host is installed with `go install` pinned to
|
||||
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
|
||||
a `go.mod` tool dependency or through a `tools.go` file, either of which
|
||||
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
|
||||
|
||||
- When pinning images or packages by hash, add a comment above the reference
|
||||
with the version and date (YYYY-MM-DD).
|
||||
@@ -382,12 +639,14 @@ style conventions are in separate documents:
|
||||
settings.
|
||||
|
||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
||||
subdirectory names:
|
||||
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||
and language-specific config). Everything else goes in a subdirectory.
|
||||
Canonical subdirectory names:
|
||||
- `bin/` — executable scripts and tools
|
||||
- `cmd/` — Go command entrypoints
|
||||
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
|
||||
body is a single call into `internal/` or `pkg/`, no project logic in
|
||||
`cmd/`
|
||||
- `configs/` — configuration templates and examples
|
||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||
- `docs/` — documentation and markdown (README.md stays in root)
|
||||
@@ -414,3 +673,7 @@ style conventions are in separate documents:
|
||||
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||
- Python: `pyproject.toml`
|
||||
|
||||
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
|
||||
is never committed under a file or directory named after one agent tool, such
|
||||
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
|
||||
|
||||
@@ -2,418 +2,71 @@
|
||||
|
||||
One issue per unit of work, one branch and one PR per issue:
|
||||
|
||||
* ensure a tracked issue exists with a definition of done
|
||||
* branch from `next` (never from `main`)
|
||||
* do the work; open a PR based on `next` (never on `main`)
|
||||
* pass an independent review, then the manager squash-merges into `next`
|
||||
* push; nothing stays local-only
|
||||
- ensure a tracked issue exists with a definition of done
|
||||
- branch from `next` (never from `main`)
|
||||
- do the work; open a PR based on `next` (never on `main`)
|
||||
- pass an independent review, then the manager squash-merges into `next`
|
||||
- push; nothing stays local-only
|
||||
|
||||
`next` is the branch for the next milestone and must stay green and
|
||||
mergeable to `main` without notice. One `next` -> `main` PR accumulates
|
||||
the milestone; releases are cut from `main` separately.
|
||||
`next` is the branch for the next milestone and must stay green and mergeable to
|
||||
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
||||
releases are cut from `main` separately.
|
||||
|
||||
Issue branches do NOT touch this file — the manager maintains it on
|
||||
`next`. Every branch editing `TODO.md` conflicts with every other
|
||||
(#112).
|
||||
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what
|
||||
is done and what is under way. This file keeps no list of either.
|
||||
|
||||
# Status
|
||||
|
||||
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
||||
authoritative list, and the only place to read a count or a state of
|
||||
play from. This file records where the project is, not what is in
|
||||
flight: a sentence whose truth depends on a branch being unmerged is
|
||||
wrong the moment it merges, and this file has been wrong that way
|
||||
before.
|
||||
What is still open for 1.0.0 is in its milestone
|
||||
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and
|
||||
only @sneak tags it.
|
||||
|
||||
The durability defect that held the tag has landed
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
|
||||
Every SQLite handle opens with WAL journaling and a busy timeout, a
|
||||
bookkeeping write that fails leaves its delivery in a recoverable
|
||||
state rather than a lying one, and recovery skips a delivery that
|
||||
already has a successful result row. Final pre-tag verification
|
||||
exercised it and confirmed it holds. Whatever the milestone still
|
||||
shows open is what remains before `v1.0.0`.
|
||||
|
||||
Delivery is at-least-once by design, not by accident: a send whose
|
||||
result row does not land is attempted again, so a receiver can see a
|
||||
duplicate. That is deliberate — the alternative is a silent lost
|
||||
delivery — and the README says so under Rationale. It is not a defect
|
||||
to re-file.
|
||||
Delivery is at-least-once by design, not by accident: a send whose result row
|
||||
does not land is attempted again, so a receiver can see a duplicate. That is
|
||||
deliberate — the alternative is a silent lost delivery — and the README says so
|
||||
under Rationale. It is not a defect to re-file.
|
||||
|
||||
# Next Step
|
||||
|
||||
Clear the rest of the open 1.0.0 milestone
|
||||
(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: `next` is kept mergeable at all times, which is the
|
||||
point of the branch.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via
|
||||
`BIND_ADDRESS` defaulting to `127.0.0.1`, and document the
|
||||
reverse-proxy deployment. A hostname, an empty value or a value
|
||||
carrying a port is a startup error, and the `Dockerfile` sets
|
||||
`0.0.0.0` because a loopback bind inside a container is unreachable
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
||||
removed the shutdown race: `httpServer` is built in the constructor
|
||||
rather than assigned from the serving goroutine, which orders the
|
||||
write before every fx hook and rules out the nil dereference a
|
||||
SIGTERM arriving first would have 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)
|
||||
- 2026-08-12 Correct release-blocking documentation inaccuracies: the
|
||||
README promised manual redelivery in the present tense in three
|
||||
places when nothing implements it (the same false claim also sat in
|
||||
the doc comment that was its source text), the env table omitted
|
||||
`RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
|
||||
units (#141)
|
||||
- 2026-08-12 Make the CI gate execute the checks it reports on. The
|
||||
workflow now writes a build-context fingerprint before calling
|
||||
`script/cibuild`, so a code commit invalidates the `COPY` layer of
|
||||
the lint and builder stages while a docs-only commit still replays
|
||||
from cache; a superseding run also rewrites the `failure` status
|
||||
Gitea leaves on commits it cancelled and never tested. Verified by
|
||||
pushing a deliberately broken test and watching CI go red (#119)
|
||||
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
|
||||
non-positive value reached `time.NewTicker` in both the retention
|
||||
reaper and the archive sweeper, panicking two goroutines with no
|
||||
recover after startup had already reported success (#140)
|
||||
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
|
||||
cap: the reverse walk cuts entries with `strings.LastIndexByte`
|
||||
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
|
||||
attacker-supplied chain cannot burn unbounded CPU in the rate-limit
|
||||
key function; running off the end falls back to the peer address
|
||||
(#124)
|
||||
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
|
||||
list: all three rate limiters key on the connection's own address
|
||||
unless the direct peer is a configured proxy, in which case
|
||||
`X-Forwarded-For` is walked right to left for the first non-proxy hop.
|
||||
Default trusts nothing, and a set-but-unparseable value aborts
|
||||
startup. Before this, any client could mint a fresh bucket or drain
|
||||
another's by rotating a spoofed header (#88)
|
||||
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
|
||||
Profile settings placeholder removed, a progressive-enhancement copy
|
||||
button for the entrypoint URL, and retention form copy that states the
|
||||
actual policy (deletion by the reaper, 0 retains forever) (#57)
|
||||
- 2026-08-11 Mask the webhook credential in delivery errors and logs:
|
||||
Go embeds the request URL in `*url.Error`, so every transport failure
|
||||
persisted the full Slack webhook URL into the per-webhook event
|
||||
database via `DeliveryResult.Error`, a field a future REST API would
|
||||
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
|
||||
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
|
||||
entrypoint path so one entrypoint cannot exhaust another's budget;
|
||||
over-limit requests get 429 with `Retry-After`. It was the one
|
||||
unauthenticated, internet-facing endpoint with no limit at all (#64)
|
||||
- 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 oversized request is rejected with 413 instead of being read in
|
||||
full by the CSRF middleware before any cap applied (#90)
|
||||
- 2026-08-11 Mask target config on the source detail page, which
|
||||
rendered the stored blob verbatim and so exposed the Slack
|
||||
incoming-webhook URL — a bearer credential that cannot be revoked
|
||||
per-holder. Config reaches the template only as a `TargetView` of
|
||||
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 written in `BeforeSave` so the GORM column default cannot
|
||||
win the race. Also bounds the reaper's cutoff arithmetic: day counts
|
||||
above 106751 overflowed `time.Duration` and wrapped the cutoff into
|
||||
the future, where every row matched and the sweep deleted everything
|
||||
(#79)
|
||||
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
||||
requests, with the 7-day absolute cap kept as an independent
|
||||
backstop that activity never extends (#66)
|
||||
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
||||
orphaned `retrying` delivery whose target type no longer supports
|
||||
retries, recording a `DeliveryResult` with the reason instead of
|
||||
leaving the delivery stuck forever (#82)
|
||||
- 2026-08-09 Root the delivery engine's worker pool and the retention
|
||||
reaper's sweep loop at `context.Background()` rather than the fx
|
||||
`OnStart` hook context (#97), which carries fx's 15s start timeout and
|
||||
killed both roughly fifteen seconds after boot: the proxy silently
|
||||
stopped delivering webhooks entirely, and the reaper never ran a
|
||||
single sweep under its default one-hour interval
|
||||
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
|
||||
last `database` target) evicts the cached archive writer and closes
|
||||
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
|
||||
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||
environment values: `envInt` removed in favour of `envPositiveInt`
|
||||
plus a `PORT` range check, `envBool` now parses with
|
||||
`strconv.ParseBool`, and defaults apply only to unset variables (#80)
|
||||
- 2026-08-07 Automatic event retention cleanup based on
|
||||
`retention_days`, deleting expired events, deliveries, and delivery
|
||||
results from each per-webhook event database (#63)
|
||||
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
|
||||
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
|
||||
all newly surfaced lint findings
|
||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
||||
Makefile shims, README Entrypoints section
|
||||
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
|
||||
plain HTTP and behind reverse proxies (#54)
|
||||
- 2026-03-17 root path redirects based on auth state (#52)
|
||||
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
|
||||
with DNS rebinding defense, and per-IP login rate limiting (#42)
|
||||
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
|
||||
absolute dev DATA_DIR default and clarified env docs (#46)
|
||||
- 2026-03-05 security headers middleware, session regeneration on
|
||||
login, request body size limits (#41)
|
||||
- 2026-03-04 tests for delivery, middleware, and session packages
|
||||
(#32); removed the build-architecture global (#31)
|
||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
||||
delivery engine with bounded worker pool and circuit breaker,
|
||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
||||
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
|
||||
into README (#6)
|
||||
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
|
||||
into `main` is a separate act from tagging and waits on neither of those: `next`
|
||||
is kept mergeable at all times, which is the point of the branch.
|
||||
|
||||
# Future Steps
|
||||
|
||||
- Delivery status and retry management UI. Replay of a terminally
|
||||
failed delivery and per-attempt detail already landed
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||
plus handler enforcement; global limits must not apply to receiver
|
||||
endpoints)
|
||||
- API key authentication for programmatic access (APIKey model exists;
|
||||
Bearer token middleware does not)
|
||||
- Per-webhook rate limits, set in the web UI and enforced in the receiver
|
||||
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT`
|
||||
sets for the whole server (see Rate Limiting in the README)
|
||||
- API key authentication for programmatic access (the `APIKey` model exists, but
|
||||
nothing creates a key and no middleware checks one)
|
||||
- REST API v1
|
||||
- CRUD for webhooks, entrypoints, targets
|
||||
- event viewing and filtering endpoints
|
||||
- event redelivery endpoint
|
||||
- OpenAPI specification
|
||||
- Analytics dashboard: success rates, response times, volume
|
||||
- A remember-me option at login
|
||||
- Password reset flow for a forgotten password over the web. The
|
||||
authenticated password *change* flow already landed, and a lost
|
||||
password is recoverable from the console with `webhooker resetpw`
|
||||
- Analytics in the web UI: response times summarised per webhook or per target,
|
||||
and figures for all webhooks together. The webhook page already shows event,
|
||||
delivery and failure counts and failure percentages, the target list counts
|
||||
each target's deliveries, the event log and each event's page show every
|
||||
attempt's duration, and `/metrics` carries server-wide counts and delivery
|
||||
durations
|
||||
- A remember-me option at login. Every session already survives a browser
|
||||
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
|
||||
(default 24 hours) without activity
|
||||
- Password reset flow for a forgotten password over the web. The authenticated
|
||||
password _change_ flow already landed, and a lost password is recoverable from
|
||||
the console with `webhooker resetpw`
|
||||
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||
- Later, nice to have
|
||||
- email delivery target type
|
||||
- SNS and S3 delivery targets
|
||||
- data transformations (e.g. webhook to Slack message formatting)
|
||||
- JSONL file delivery with periodic S3 upload
|
||||
- webhook event search and filtering
|
||||
- multi-user with role-based access control
|
||||
- data transformations (e.g. a configurable Slack message format; the Slack
|
||||
target sends one fixed format today)
|
||||
- JSONL file delivery with periodic S3 upload (the `database` target already
|
||||
archives events to SQLite files, rotated hourly, daily or monthly if set,
|
||||
and the webhook page offers them as one gzipped JSON download)
|
||||
- webhook event search, and filtering beyond the event log's failed and
|
||||
pending views
|
||||
- multi-user with role-based access control (each webhook already belongs to
|
||||
one account, but there is only the account made at first boot and no way
|
||||
to add another)
|
||||
|
||||
+11
-8
@@ -4,11 +4,13 @@ package main
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxevent"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
@@ -48,7 +50,7 @@ import (
|
||||
// before the server may already have spent.
|
||||
//
|
||||
// This does not make the database close unconditional: the
|
||||
// ArchiveSweeper and RetentionReaper hooks run before the server.
|
||||
// archive.Sweeper and RetentionReaper hooks run before the server.
|
||||
// What they spend comes out of the drain first, but past 3s it comes
|
||||
// out of the reserve, and they can consume the whole budget.
|
||||
const stopTimeout = 5 * time.Second
|
||||
@@ -187,6 +189,9 @@ func newApp() *fx.App {
|
||||
fx.Provide(
|
||||
globals.New,
|
||||
logger.New,
|
||||
// The plain logger the session, the middleware, the
|
||||
// webhook database manager and the archive writers take.
|
||||
func(l *logger.Logger) *slog.Logger { return l.Get() },
|
||||
config.New,
|
||||
database.New,
|
||||
database.NewWebhookDBManager,
|
||||
@@ -204,14 +209,12 @@ func newApp() *fx.App {
|
||||
// disagree about a destination.
|
||||
delivery.NewGuard,
|
||||
delivery.New,
|
||||
delivery.NewArchiveSweeper,
|
||||
// Wire *delivery.Engine as delivery.Notifier so the
|
||||
// webhook handler can notify the engine of new deliveries.
|
||||
func(e *delivery.Engine) delivery.Notifier { return e },
|
||||
// Wire *delivery.Engine as delivery.Archives so deleting
|
||||
archive.NewWriters,
|
||||
archive.NewSweeper,
|
||||
// Wire *archive.Writers as archive.Archives so deleting
|
||||
// or renaming a webhook or target reaches its archive
|
||||
// files.
|
||||
func(e *delivery.Engine) delivery.Archives { return e },
|
||||
func(w *archive.Writers) archive.Archives { return w },
|
||||
// Wire *delivery.Engine as delivery.CircuitBreakers so
|
||||
// the pages can show a target whose deliveries are
|
||||
// paused.
|
||||
@@ -223,7 +226,7 @@ func newApp() *fx.App {
|
||||
*server.Server,
|
||||
*delivery.Engine,
|
||||
*database.RetentionReaper,
|
||||
*delivery.ArchiveSweeper,
|
||||
*archive.Sweeper,
|
||||
) {
|
||||
},
|
||||
),
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||
"sneak.berlin/go/webhooker/internal/server"
|
||||
@@ -37,7 +37,7 @@ const dockerStopGrace = 10 * time.Second
|
||||
// fx.New applies options before it executes invokes, so the timeout
|
||||
// is set whether or not the graph itself can be constructed here.
|
||||
func TestNewApp_StopTimeout(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
|
||||
got := newApp().StopTimeout()
|
||||
@@ -75,7 +75,7 @@ func freePort(t *testing.T) int {
|
||||
// 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)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("DATA_DIR", t.TempDir())
|
||||
t.Setenv("PORT", strconv.Itoa(freePort(t)))
|
||||
t.Setenv("DEBUG", "true")
|
||||
@@ -252,7 +252,7 @@ const tailHeadroom = 2 * time.Second
|
||||
// can produce, since a shorter drain leaves the flush more room and
|
||||
// the worst case is not necessarily at either extreme.
|
||||
//
|
||||
// Nor does the hook start on a full budget: the ArchiveSweeper and
|
||||
// Nor does the hook start on a full budget: the archive.Sweeper and
|
||||
// RetentionReaper hooks run before it, and whatever they spent is
|
||||
// gone. The outer sweep walks every amount they can spend. Once they
|
||||
// have eaten into the headroom themselves, the hook must spend
|
||||
|
||||
@@ -22,7 +22,6 @@ require (
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
gorm.io/driver/sqlite v1.5.4
|
||||
gorm.io/gorm v1.25.5
|
||||
modernc.org/sqlite v1.28.0
|
||||
@@ -59,6 +58,7 @@ require (
|
||||
golang.org/x/text v0.25.0 // indirect
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||
google.golang.org/protobuf v1.31.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
lukechampine.com/uint128 v1.2.0 // indirect
|
||||
modernc.org/cc/v3 v3.40.0 // indirect
|
||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
// Package archive manages the SQLite archive files the database target
|
||||
// writes: how they are named and rotated, the writers that open, write,
|
||||
// close and reopen them, pruning the rows past a target's expiry,
|
||||
// renaming a target's files and moving them back when that fails, the
|
||||
// gzipped JSON export, the idle sweep, and the locks that keep a rename
|
||||
// apart from archive writes, from other edits and from exports.
|
||||
package archive
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// archiveNameMaxLen is how many characters of a webhook or target
|
||||
// name an archive file name keeps.
|
||||
const archiveNameMaxLen = 40
|
||||
|
||||
// Archives is how the handlers keep the database targets' archive
|
||||
// files in step with the configuration. Deleting a webhook or a
|
||||
// target releases the cached archive writers, whose open file
|
||||
// handles would otherwise outlive them; renaming one renames the
|
||||
// archive files, which are named for the webhook and the target
|
||||
// (see FileName). RenameLock returns the lock every rename runs under
|
||||
// (see Writers.RenameLock).
|
||||
//
|
||||
// It is an interface so that the handlers' tests can stand in for
|
||||
// Writers.
|
||||
//
|
||||
// Neither eviction deletes an archive file. Both are idempotent
|
||||
// and are no-ops for a webhook or target with no writer.
|
||||
type Archives interface {
|
||||
RenameLock() *sync.Mutex
|
||||
EvictWebhook(webhookID string)
|
||||
EvictTarget(targetID string)
|
||||
Rename(targetID, webhookName, targetName string) error
|
||||
}
|
||||
|
||||
// WritersParams holds the fx dependencies for Writers.
|
||||
type WritersParams struct {
|
||||
fx.In
|
||||
|
||||
Database *database.Database
|
||||
DBManager *database.WebhookDBManager
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// Writers holds the archive writer of each database target, and the
|
||||
// lock every rename of archive files runs under. The database target
|
||||
// writes an event through it, the handlers rename and evict through
|
||||
// it, the archive sweeper prunes through it, and the delivery engine
|
||||
// evicts every writer when it stops. See archiveWriter for the
|
||||
// close/reopen, auto-recreate, and expiry semantics.
|
||||
type Writers struct {
|
||||
db *database.Database
|
||||
dbMgr *database.WebhookDBManager
|
||||
log *slog.Logger
|
||||
|
||||
// writers holds one archive writer per database target, keyed
|
||||
// by target ID.
|
||||
mu sync.Mutex
|
||||
writers map[string]*archiveWriter
|
||||
|
||||
// renameMu makes the webhook edit, the target edit and target
|
||||
// creation run one at a time, each held from loading the stored
|
||||
// names through the archive rename, the save and any move back.
|
||||
// Interleaved, one could rename an archive between another's
|
||||
// rename and save, leaving the file named for one edit and the
|
||||
// stored names from the other. An archive download holds it while
|
||||
// it reads the stored names and lists the files they give, and
|
||||
// again for each file while it finds the file under the names
|
||||
// stored then and opens it. RenameLock returns it.
|
||||
renameMu sync.Mutex
|
||||
}
|
||||
|
||||
// NewWriters builds the archive writers. It opens no archive file:
|
||||
// each target's writer is made on its first use.
|
||||
func NewWriters(params WritersParams) *Writers {
|
||||
return &Writers{
|
||||
db: params.Database,
|
||||
dbMgr: params.DBManager,
|
||||
log: params.Logger,
|
||||
}
|
||||
}
|
||||
|
||||
// RenameLock implements Archives. It returns the lock every rename of
|
||||
// archive files runs under: see renameMu for who holds it and for how
|
||||
// long. Archive writes and Rename itself do not take it.
|
||||
func (ws *Writers) RenameLock() *sync.Mutex {
|
||||
return &ws.renameMu
|
||||
}
|
||||
|
||||
// FileName returns the file name of a database target's
|
||||
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
|
||||
// names passed through archiveNamePart. The target ID keeps the
|
||||
// name unique when two targets' names come out the same.
|
||||
func FileName(webhookName, targetName, targetID string) string {
|
||||
return "archive-" + archiveNamePart(webhookName) + "-" +
|
||||
archiveNamePart(targetName) + "-" + targetID + ".db"
|
||||
}
|
||||
|
||||
// archiveNamePart makes a webhook or target name safe to put in a
|
||||
// file name. It is lowercased; ASCII letters and digits are kept,
|
||||
// every other run of characters becomes a single "-", and no "-" is
|
||||
// left at either end. It is cut to archiveNameMaxLen characters, and
|
||||
// a name with nothing left is "unnamed".
|
||||
func archiveNamePart(name string) string {
|
||||
var b strings.Builder
|
||||
|
||||
dash := false
|
||||
|
||||
for _, r := range strings.ToLower(name) {
|
||||
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
|
||||
dash = b.Len() > 0
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if dash {
|
||||
b.WriteByte('-')
|
||||
|
||||
dash = false
|
||||
}
|
||||
|
||||
b.WriteRune(r)
|
||||
}
|
||||
|
||||
part := b.String()
|
||||
if len(part) > archiveNameMaxLen {
|
||||
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
|
||||
}
|
||||
|
||||
if part == "" {
|
||||
return "unnamed"
|
||||
}
|
||||
|
||||
return part
|
||||
}
|
||||
|
||||
// Write writes the full event of a delivery to a database target as
|
||||
// a row into the target's archive database, honouring the target's
|
||||
// ArchiveExpiry and ArchiveRotation. With rotation, the event goes to
|
||||
// the file for the period of its receive time.
|
||||
func (ws *Writers) Write(d *database.Delivery) error {
|
||||
webhookID := d.Event.WebhookID
|
||||
if webhookID == "" {
|
||||
return errArchiveMissingWebhookID
|
||||
}
|
||||
|
||||
// An event whose stored row was gone before its delivery ran has
|
||||
// no receive time (see delivery's Engine.hydrateEvent), and goes
|
||||
// to the file for now.
|
||||
receivedAt := d.Event.CreatedAt
|
||||
if receivedAt.IsZero() {
|
||||
receivedAt = time.Now()
|
||||
}
|
||||
|
||||
w, err := ws.writerFor(d.TargetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
row := archivedEvent{
|
||||
EventID: d.Event.ID,
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: d.Event.EntrypointID,
|
||||
Method: d.Event.Method,
|
||||
RawQuery: d.Event.RawQuery,
|
||||
Headers: d.Event.Headers,
|
||||
Body: d.Event.Body,
|
||||
ContentType: d.Event.ContentType,
|
||||
}
|
||||
|
||||
return w.write(
|
||||
row, d.Target.ArchiveExpiry,
|
||||
archivePeriod(d.Target.ArchiveRotation, receivedAt),
|
||||
)
|
||||
}
|
||||
|
||||
// Rename implements Archives. It renames every one of a database
|
||||
// target's archive files to FileName(webhookName, targetName,
|
||||
// targetID), each keeping the period in its name. It goes through the
|
||||
// target's writer, so the move holds the lock that the target's
|
||||
// archive writes and the idle sweep take, and later writes use the
|
||||
// new name. It never replaces a file: if one already has a new name,
|
||||
// the error is ErrNameTaken.
|
||||
//
|
||||
// The writer is created if there is none, and it stays cached. The
|
||||
// handlers rename before they save the new name, so until the save
|
||||
// the main database still has the old one; a delivery in that window
|
||||
// must find this writer rather than build one from the old name.
|
||||
func (ws *Writers) Rename(
|
||||
targetID, webhookName, targetName string,
|
||||
) error {
|
||||
w, err := ws.writerFor(targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return w.rename(FileName(webhookName, targetName, targetID))
|
||||
}
|
||||
|
||||
// EvictTarget implements Archives. It drops a database target's
|
||||
// archive writer from the registry and closes its handle, so a
|
||||
// deleted target does not leave a writer (and an open archive handle
|
||||
// within its debounce window) alive for the process lifetime.
|
||||
//
|
||||
// The map entry is removed under the registry lock, which is
|
||||
// then released before the handle is closed under the writer's
|
||||
// own lock: that ordering keeps the registry available to other
|
||||
// targets while an in-flight write on this one drains, and
|
||||
// closing under the writer's lock means eviction can never race
|
||||
// a write.
|
||||
//
|
||||
// Eviction is idempotent and silent for a target with no writer,
|
||||
// which is the common case: only a database target that has
|
||||
// received an event or been renamed has one. It never deletes the
|
||||
// archive file.
|
||||
func (ws *Writers) EvictTarget(targetID string) {
|
||||
ws.mu.Lock()
|
||||
|
||||
w, ok := ws.writers[targetID]
|
||||
if ok {
|
||||
delete(ws.writers, targetID)
|
||||
}
|
||||
|
||||
ws.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
w.evict()
|
||||
|
||||
ws.log.Info(
|
||||
"evicted archive writer",
|
||||
"target_id", targetID,
|
||||
"path", w.path,
|
||||
)
|
||||
}
|
||||
|
||||
// EvictWebhook implements Archives. It evicts, exactly as EvictTarget
|
||||
// does, the writer of every database target of a webhook. The archive
|
||||
// files themselves are left on disk — they are long-term storage the
|
||||
// operator owns.
|
||||
func (ws *Writers) EvictWebhook(webhookID string) {
|
||||
ws.mu.Lock()
|
||||
|
||||
var gone []*archiveWriter
|
||||
|
||||
for targetID, w := range ws.writers {
|
||||
if w.webhookID == webhookID {
|
||||
delete(ws.writers, targetID)
|
||||
|
||||
gone = append(gone, w)
|
||||
}
|
||||
}
|
||||
|
||||
ws.mu.Unlock()
|
||||
|
||||
for _, w := range gone {
|
||||
w.evict()
|
||||
|
||||
ws.log.Info(
|
||||
"evicted archive writer",
|
||||
"webhook_id", webhookID,
|
||||
"path", w.path,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// EvictAll evicts every cached archive writer, exactly as EvictTarget
|
||||
// does for one target. The delivery engine calls it at shutdown, once
|
||||
// its workers have returned. Closing the last handle on an archive
|
||||
// moves the contents of its -wal into the .db and removes the
|
||||
// -wal, so a clean stop leaves each archive as a single file.
|
||||
func (ws *Writers) EvictAll() {
|
||||
ws.mu.Lock()
|
||||
|
||||
writers := ws.writers
|
||||
ws.writers = nil
|
||||
|
||||
ws.mu.Unlock()
|
||||
|
||||
for _, w := range writers {
|
||||
w.evict()
|
||||
}
|
||||
}
|
||||
|
||||
// writerFor returns the archive writer for a database target,
|
||||
// creating and caching it on first use. Each target has one writer
|
||||
// so its close/reopen debounce state is shared across concurrent
|
||||
// deliveries, and so a rename and the idle sweep take the same lock
|
||||
// as its writes.
|
||||
func (ws *Writers) writerFor(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
w, ok := ws.writers[targetID]
|
||||
if !ok {
|
||||
var err error
|
||||
|
||||
w, err = ws.newWriter(targetID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if ws.writers == nil {
|
||||
ws.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
ws.writers[targetID] = w
|
||||
}
|
||||
|
||||
// A delivery claims the entry: even if the idle sweep created
|
||||
// it moments ago, it now belongs to the registry proper and
|
||||
// the sweep must leave it in place when it finishes.
|
||||
w.sweepOwned = false
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// sweepWriterFor returns the archive writer the idle sweep should
|
||||
// prune a target's archive through, together with whether the sweep
|
||||
// itself created the registry entry.
|
||||
//
|
||||
// The sweep must route its prune through the registered writer so
|
||||
// the writer's mutex orders it against concurrent writes, but it
|
||||
// must never leave a registry entry behind: a sweep that ran
|
||||
// concurrently with the target's deletion would otherwise
|
||||
// re-create an entry that nothing will ever evict again, which is
|
||||
// exactly the leak eviction exists to prevent. An entry the sweep
|
||||
// creates is therefore marked sweep-owned and handed back to
|
||||
// releaseSweepWriter when the sweep is done.
|
||||
func (ws *Writers) sweepWriterFor(
|
||||
targetID string,
|
||||
) (*archiveWriter, bool, error) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
w, ok := ws.writers[targetID]
|
||||
if ok {
|
||||
return w, false, nil
|
||||
}
|
||||
|
||||
w, err := ws.newWriter(targetID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if ws.writers == nil {
|
||||
ws.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
w.sweepOwned = true
|
||||
ws.writers[targetID] = w
|
||||
|
||||
return w, true, nil
|
||||
}
|
||||
|
||||
// releaseSweepWriter drops a registry entry that the idle sweep
|
||||
// created, so a sweep leaves the registry exactly as it found it.
|
||||
//
|
||||
// The entry is removed only if it is still the very writer the
|
||||
// sweep installed and no delivery has claimed it in the meantime
|
||||
// (writerFor clears sweepOwned when it hands a writer to the
|
||||
// write path). Both conditions are evaluated under the registry
|
||||
// lock, so an eviction that raced the sweep — which removes the
|
||||
// entry outright — simply finds nothing left to do here, and a
|
||||
// delivery that adopted the writer keeps a registered, evictable
|
||||
// one.
|
||||
func (ws *Writers) releaseSweepWriter(
|
||||
targetID string, w *archiveWriter,
|
||||
) {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
cur, ok := ws.writers[targetID]
|
||||
if !ok || cur != w || !cur.sweepOwned {
|
||||
return
|
||||
}
|
||||
|
||||
delete(ws.writers, targetID)
|
||||
}
|
||||
|
||||
// newWriter builds the writer for a database target's archive. Its
|
||||
// path is the one Path gives for the webhook and the target as
|
||||
// the main database names them now; from then on only rename changes
|
||||
// the name the writer uses. It does not touch the archive files.
|
||||
func (ws *Writers) newWriter(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
if ws.dbMgr == nil {
|
||||
return nil, errArchiveNoDataDir
|
||||
}
|
||||
|
||||
var target database.Target
|
||||
|
||||
err := ws.db.DB().
|
||||
Preload("Webhook").
|
||||
First(&target, "id = ?", targetID).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"loading database target %s: %w", targetID, err,
|
||||
)
|
||||
}
|
||||
|
||||
w := newArchiveWriter(
|
||||
Path(ws.dbMgr, &target.Webhook, &target),
|
||||
ws.log,
|
||||
)
|
||||
w.webhookID = target.WebhookID
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// sweepArchive prunes one database target's archive of rows older
|
||||
// than expiry, without requiring a write. A missing archive file is
|
||||
// left missing (see sweepExpired), so a sweep never creates an
|
||||
// archive for a target that has never received an event.
|
||||
//
|
||||
// It also never leaves a registry entry behind: an entry it had
|
||||
// to create to reach the writer's mutex is released again once
|
||||
// the prune is done, so a sweep racing a target deletion cannot
|
||||
// resurrect the writer the eviction just dropped.
|
||||
func (ws *Writers) sweepArchive(
|
||||
targetID string, expiry time.Duration,
|
||||
) error {
|
||||
w, created, err := ws.sweepWriterFor(targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if created {
|
||||
defer ws.releaseSweepWriter(targetID, w)
|
||||
}
|
||||
|
||||
return w.sweepExpired(expiry)
|
||||
}
|
||||
@@ -0,0 +1,757 @@
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
const (
|
||||
// testContentType is the event content type used in tests.
|
||||
testContentType = "application/json"
|
||||
|
||||
// eventQuery is the query string an archived event carries.
|
||||
eventQuery = "a=1&b=2"
|
||||
)
|
||||
|
||||
func archiveTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
))
|
||||
}
|
||||
|
||||
// openArchiveDBForRead opens an archive file read-only so a
|
||||
// test can inspect the rows the writer persisted.
|
||||
func openArchiveDBForRead(
|
||||
t *testing.T, path string,
|
||||
) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite",
|
||||
fmt.Sprintf("file:%s?mode=ro", path),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return gdb
|
||||
}
|
||||
|
||||
// archiveFileSuffixes returns the archive file itself and the
|
||||
// SQLite sidecars that accompany an open database. A test that
|
||||
// asserts no archive was created has to check all of them.
|
||||
func archiveFileSuffixes() []string {
|
||||
return []string{"", "-wal", "-shm"}
|
||||
}
|
||||
|
||||
// removeArchiveFiles simulates an operator moving the archive
|
||||
// away by deleting the SQLite file and its sidecar files.
|
||||
func removeArchiveFiles(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
for _, suffix := range []string{
|
||||
"", "-wal", "-shm", "-journal",
|
||||
} {
|
||||
err := os.Remove(path + suffix)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("removing %s%s: %v", path, suffix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
|
||||
// is in the form GORM stores created_at in: the layout of
|
||||
// time.Time.String, with no " m=+…" monotonic clock reading after it.
|
||||
func assertStoredTime(t *testing.T, text string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := time.Parse(
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST", text,
|
||||
)
|
||||
assert.NoError(t, err, "stored as %q", text)
|
||||
}
|
||||
|
||||
// newEvent returns an event received now with the given body, as the
|
||||
// webhook handler stores it.
|
||||
func newEvent(body string) database.Event {
|
||||
return database.Event{
|
||||
BaseModel: database.BaseModel{
|
||||
ID: uuid.New().String(),
|
||||
CreatedAt: time.Now(),
|
||||
},
|
||||
WebhookID: uuid.New().String(),
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: http.MethodPost,
|
||||
Headers: `{"Content-Type":["application/json"]}`,
|
||||
Body: body,
|
||||
ContentType: testContentType,
|
||||
}
|
||||
}
|
||||
|
||||
// databaseTargetDelivery returns a delivery of event to a database
|
||||
// target, as the database target hands it to Writers.Write.
|
||||
func databaseTargetDelivery(
|
||||
event database.Event, tgt *database.Target,
|
||||
) *database.Delivery {
|
||||
return &database.Delivery{
|
||||
EventID: event.ID,
|
||||
TargetID: tgt.ID,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: tgt.Name,
|
||||
Type: database.TargetTypeDatabase,
|
||||
ArchiveExpiry: tgt.ArchiveExpiry,
|
||||
ArchiveRotation: tgt.ArchiveRotation,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestArchiveWriter_WritesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
row := archive.ExportArchivedEvent{
|
||||
EventID: "ev-1",
|
||||
WebhookID: "wh-1",
|
||||
EntrypointID: "ep-1",
|
||||
Method: "POST",
|
||||
Headers: `{"X":"Y"}`,
|
||||
Body: `{"hello":"world"}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
|
||||
require.NoError(t, w.Write(row, 0))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
var got []archive.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "ev-1", got[0].EventID)
|
||||
assert.Equal(t, "wh-1", got[0].WebhookID)
|
||||
assert.Equal(t, "ep-1", got[0].EntrypointID)
|
||||
assert.Equal(t, row.Method, got[0].Method)
|
||||
assert.Equal(t, row.ContentType, got[0].ContentType)
|
||||
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
||||
assert.False(t, got[0].ArchivedAt.IsZero())
|
||||
|
||||
var archivedAt string
|
||||
|
||||
require.NoError(t, w.DB().Raw(
|
||||
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
|
||||
).Row().Scan(&archivedAt))
|
||||
assertStoredTime(t, archivedAt)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_RecreatesAfterRemoval(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
archive.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
// The operator moves the archive away while the handle is
|
||||
// still open.
|
||||
removeArchiveFiles(t, path)
|
||||
require.NoFileExists(t, path)
|
||||
|
||||
// The next write recreates the file with a fresh schema and
|
||||
// only the new row.
|
||||
require.NoError(t, w.Write(
|
||||
archive.ExportArchivedEvent{EventID: "b"}, 0,
|
||||
))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
var got []archive.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "b", got[0].EventID)
|
||||
}
|
||||
|
||||
// TestArchiveHandleIsWAL closes the last gap in the durability
|
||||
// evidence: the main and per-webhook tiers each assert their journal
|
||||
// mode on a live handle, and the archive tier gets its settings from
|
||||
// the same code path but nothing checked the running file.
|
||||
func TestArchiveHandleIsWAL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
w := archive.NewExportArchiveWriter(
|
||||
filepath.Join(t.TempDir(), "archive-wal.db"),
|
||||
archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Open(0))
|
||||
|
||||
var mode string
|
||||
|
||||
row := w.DB().Raw("pragma journal_mode").Row()
|
||||
require.NoError(t, row.Scan(&mode))
|
||||
assert.Equal(t, "wal", strings.ToLower(mode))
|
||||
|
||||
var busy string
|
||||
|
||||
row = w.DB().Raw("pragma busy_timeout").Row()
|
||||
require.NoError(t, row.Scan(&busy))
|
||||
assert.Equal(t, "10000", busy)
|
||||
}
|
||||
|
||||
// TestStatArchive proves Stat finds no file before the first
|
||||
// write; after a write still held in the -wal, counts the -wal in the
|
||||
// size and takes its later time as the last write; and finds no file
|
||||
// again once the file has been moved away.
|
||||
func TestStatArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
|
||||
_, err := archive.Stat(path)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
|
||||
// With the clock stopped, the reopen debounce never passes, so
|
||||
// the handle stays open after the write.
|
||||
stopped := time.Now()
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w.SetNow(func() time.Time { return stopped })
|
||||
|
||||
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "a"}, 0))
|
||||
|
||||
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
earlier := written.Add(-time.Hour)
|
||||
require.NoError(t, os.Chtimes(path, earlier, earlier))
|
||||
require.NoError(t, os.Chtimes(path+"-wal", written, written))
|
||||
|
||||
file, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
wal, err := os.Stat(path + "-wal")
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, wal.Size())
|
||||
|
||||
got, err := archive.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, got.Files)
|
||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
||||
assert.True(t, written.Equal(got.Written), got.Written)
|
||||
|
||||
removeArchiveFiles(t, path)
|
||||
|
||||
_, err = archive.Stat(path)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const debounce = 2 * time.Second
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), debounce,
|
||||
)
|
||||
|
||||
// The writer measures its reopen debounce on this clock, which
|
||||
// only the test moves, so how long the host takes between
|
||||
// writes cannot change the result.
|
||||
now := time.Now()
|
||||
|
||||
w.SetNow(func() time.Time { return now })
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
archive.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
require.NoError(t, w.Write(
|
||||
archive.ExportArchivedEvent{EventID: "b"}, 0,
|
||||
))
|
||||
|
||||
// Two writes inside the debounce window trigger only the
|
||||
// initial open — no extra close/reopen.
|
||||
assert.Equal(t, 1, w.Reopens())
|
||||
|
||||
now = now.Add(debounce)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
archive.ExportArchivedEvent{EventID: "c"}, 0,
|
||||
))
|
||||
|
||||
// A write after the window elapses closes and reopens once.
|
||||
assert.Equal(t, 2, w.Reopens())
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Open(0))
|
||||
|
||||
old := archive.ExportArchivedEvent{
|
||||
EventID: "old",
|
||||
ArchivedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
fresh := archive.ExportArchivedEvent{
|
||||
EventID: "fresh",
|
||||
ArchivedAt: time.Now(),
|
||||
}
|
||||
|
||||
require.NoError(t, w.DB().Create(&old).Error)
|
||||
require.NoError(t, w.DB().Create(&fresh).Error)
|
||||
|
||||
// Reopening with a one-hour expiry prunes the old row.
|
||||
require.NoError(t, w.Reopen(time.Hour))
|
||||
|
||||
var got []archive.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "fresh", got[0].EventID)
|
||||
}
|
||||
|
||||
func TestParseArchiveExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty", "", 0, false},
|
||||
{"explicit never", "never", 0, false},
|
||||
{"hour", "1h", time.Hour, false},
|
||||
{"thirty days", "720h", 720 * time.Hour, false},
|
||||
{"minutes", "30m", 30 * time.Minute, false},
|
||||
{"unparseable", "nonsense", 0, true},
|
||||
{"days are not a Go unit", "7d", 0, true},
|
||||
{"zero duration", "0s", 0, true},
|
||||
{"bare zero", "0", 0, true},
|
||||
{"negative duration", "-5h", 0, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := archive.ParseExpiry(tc.in)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArchiveFileName pins the archive file name and the rules
|
||||
// that make a webhook or target name safe to put in it.
|
||||
func TestArchiveFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
webhook string
|
||||
target string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"plain names", "orders", "archive",
|
||||
"archive-orders-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"lowercased", "Orders", "Main Archive",
|
||||
"archive-orders-main-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"a run of other characters is one dash",
|
||||
`a /\..b`, "c__--d",
|
||||
"archive-a-b-c-d-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"no dash at either end", " --orders!! ", "(archive)",
|
||||
"archive-orders-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"path separators", "../../etc/passwd", "a/b",
|
||||
"archive-etc-passwd-a-b-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"letters outside ASCII are dropped",
|
||||
"Bestellungen Größe", "café",
|
||||
"archive-bestellungen-gr-e-caf-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"nothing left is unnamed", "", "!!!",
|
||||
"archive-unnamed-unnamed-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"cut to 40 characters", strings.Repeat("a", 50), "x",
|
||||
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"no dash left by the cut",
|
||||
strings.Repeat("a", 39) + " b", "x",
|
||||
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want,
|
||||
archive.FileName(tc.webhook, tc.target, id),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrite_EachTargetHasItsOwnArchive proves two
|
||||
// database targets of one webhook archive into separate files.
|
||||
func TestWrite_EachTargetHasItsOwnArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
first := env.seedDatabaseTarget(t, 0, "")
|
||||
second := env.addDatabaseTarget(t, first.WebhookID, 0, "")
|
||||
|
||||
event := newEvent(`{"n":1}`)
|
||||
|
||||
for _, tgt := range []*database.Target{first, second} {
|
||||
require.NoError(t, env.writers.Write(
|
||||
databaseTargetDelivery(event, tgt),
|
||||
))
|
||||
}
|
||||
|
||||
require.NotEqual(
|
||||
t, env.archivePath(first), env.archivePath(second),
|
||||
)
|
||||
assert.Equal(
|
||||
t, []string{event.ID},
|
||||
archivedEventIDs(t, env.archivePath(first)),
|
||||
)
|
||||
assert.Equal(
|
||||
t, []string{event.ID},
|
||||
archivedEventIDs(t, env.archivePath(second)),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRename_MovesTheFile proves a rename moves the archive, rows
|
||||
// and all, and that later writes go to the new name.
|
||||
func TestRename_MovesTheFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
oldPath := env.archivePath(tgt)
|
||||
|
||||
first := newEvent(`{"n":1}`)
|
||||
require.NoError(t, env.writers.Write(databaseTargetDelivery(first, tgt)))
|
||||
require.FileExists(t, oldPath)
|
||||
|
||||
require.NoError(
|
||||
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
)
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
|
||||
assert.NoFileExists(t, oldPath)
|
||||
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
|
||||
|
||||
second := newEvent(`{"n":2}`)
|
||||
require.NoError(t, env.writers.Write(databaseTargetDelivery(second, tgt)))
|
||||
|
||||
assert.ElementsMatch(
|
||||
t, []string{first.ID, second.ID},
|
||||
archivedEventIDs(t, newPath),
|
||||
)
|
||||
assert.NoFileExists(
|
||||
t, oldPath, "a write after the rename must use the new name",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRename_NeverReplacesAFile plants a file at the new name, once
|
||||
// the .db alone, once a lone -wal and once a lone -shm, and proves
|
||||
// each time that the rename is refused, the planted file survives,
|
||||
// and the archive keeps its name and its rows.
|
||||
func TestRename_NeverReplacesAFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
t.Run("planted .db"+suffix, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
oldPath := env.archivePath(tgt)
|
||||
|
||||
first := newEvent(`{"n":1}`)
|
||||
require.NoError(
|
||||
t, env.writers.Write(databaseTargetDelivery(first, tgt)),
|
||||
)
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
plantedPath := newPath + suffix
|
||||
require.NoError(
|
||||
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
|
||||
)
|
||||
|
||||
require.ErrorIs(
|
||||
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
archive.ErrNameTaken,
|
||||
)
|
||||
|
||||
//nolint:gosec // reads the file the test planted under t.TempDir()
|
||||
planted, err := os.ReadFile(plantedPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "planted", string(planted))
|
||||
|
||||
second := newEvent(`{"n":2}`)
|
||||
require.NoError(
|
||||
t, env.writers.Write(databaseTargetDelivery(second, tgt)),
|
||||
)
|
||||
|
||||
assert.ElementsMatch(
|
||||
t, []string{first.ID, second.ID},
|
||||
archivedEventIDs(t, oldPath),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRename_BeforeTheNameIsSaved covers the order the handlers
|
||||
// use: they rename before they save the new name, so a delivery in
|
||||
// between must write under the new name although the main database
|
||||
// still has the old one. It also shows that renaming an archive that
|
||||
// does not exist yet is not an error.
|
||||
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
|
||||
require.NoError(
|
||||
t, env.writers.Rename(tgt.ID, "Orders", "Archive"),
|
||||
)
|
||||
|
||||
event := newEvent(`{"n":1}`)
|
||||
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
|
||||
|
||||
assert.FileExists(
|
||||
t,
|
||||
filepath.Join(
|
||||
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
|
||||
),
|
||||
)
|
||||
assert.NoFileExists(t, env.archivePath(tgt))
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
|
||||
// the -wal and -shm a crash can leave beside an archive no handle
|
||||
// has opened since. SQLite finds them by name, so a -wal left
|
||||
// behind would lose the transactions it holds.
|
||||
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
newPath := filepath.Join(dir, "archive-new.db")
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
require.NoError(
|
||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||
)
|
||||
}
|
||||
|
||||
w := archive.NewExportArchiveWriter(
|
||||
oldPath, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Rename("archive-new.db"))
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
assert.NoFileExists(t, oldPath+suffix)
|
||||
assert.FileExists(t, newPath+suffix)
|
||||
}
|
||||
|
||||
assert.Equal(t, newPath, w.Path())
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
|
||||
// move after the .db has moved, and proves the .db is moved back, so
|
||||
// the archive is never split across two names. The new name is 255
|
||||
// bytes, the longest a file name may be, so the .db can take it but
|
||||
// the -wal, four bytes longer, cannot.
|
||||
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
newName := strings.Repeat("a", 252) + ".db"
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
require.NoError(
|
||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||
)
|
||||
}
|
||||
|
||||
w := archive.NewExportArchiveWriter(
|
||||
oldPath, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.Error(t, w.Rename(newName))
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
assert.FileExists(t, oldPath+suffix)
|
||||
}
|
||||
|
||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||
assert.Equal(t, oldPath, w.Path())
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
|
||||
// file without a period and a file for a month, and proves that when
|
||||
// the month's file fails to move, the file already moved is moved back:
|
||||
// both files are under the old name with their rows, and nothing is
|
||||
// under the new name. The new name is 251 bytes, so the file without a
|
||||
// period, with its -wal and -shm, can take it, but the month's file,
|
||||
// eight bytes longer, cannot.
|
||||
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
|
||||
w := archive.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
|
||||
|
||||
require.NoError(t, w.WritePeriod(
|
||||
archive.ExportArchivedEvent{EventID: "in-none"}, 0, "",
|
||||
))
|
||||
require.NoError(t, w.WritePeriod(
|
||||
archive.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
|
||||
))
|
||||
|
||||
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
|
||||
|
||||
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
|
||||
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, entry := range entries {
|
||||
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
|
||||
"%s is not under the old name", entry.Name())
|
||||
}
|
||||
|
||||
assert.Equal(t, oldPath, w.Path())
|
||||
}
|
||||
|
||||
// TestRenameLock_HeldByOneEditAtATime holds the rename lock, as an edit
|
||||
// does from loading the stored names until it has saved them. While it
|
||||
// is held, the edit's rename and an archive write still finish, since
|
||||
// neither takes it, and the event ends up under the new name whichever
|
||||
// ran first. A second edit waits for the lock until the first lets go.
|
||||
func TestRenameLock_HeldByOneEditAtATime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
event := newEvent(`{"n":1}`)
|
||||
|
||||
env.writers.RenameLock().Lock()
|
||||
|
||||
finished := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
finished <- env.writers.Rename(tgt.ID, "Orders", "Long Term")
|
||||
}()
|
||||
go func() {
|
||||
finished <- env.writers.Write(databaseTargetDelivery(event, tgt))
|
||||
}()
|
||||
|
||||
for range 2 {
|
||||
select {
|
||||
case err := <-finished:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("a rename or a write waited for the rename lock")
|
||||
}
|
||||
}
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
assert.Equal(t, []string{event.ID}, archivedEventIDs(t, newPath))
|
||||
|
||||
secondEdit := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
env.writers.RenameLock().Lock()
|
||||
close(secondEdit)
|
||||
env.writers.RenameLock().Unlock()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-secondEdit:
|
||||
t.Fatal("a second edit took the rename lock while it was held")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
|
||||
env.writers.RenameLock().Unlock()
|
||||
|
||||
select {
|
||||
case <-secondEdit:
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("a second edit did not get the rename lock once released")
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package delivery
|
||||
package archive
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
@@ -28,25 +28,25 @@ import (
|
||||
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
|
||||
// Path 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(
|
||||
// name FileName gives it.
|
||||
func Path(
|
||||
dbMgr *database.WebhookDBManager,
|
||||
webhook *database.Webhook,
|
||||
target *database.Target,
|
||||
) string {
|
||||
return filepath.Join(
|
||||
filepath.Dir(dbMgr.DBPath(webhook.ID)),
|
||||
ArchiveFileName(webhook.Name, target.Name, target.ID),
|
||||
FileName(webhook.Name, target.Name, target.ID),
|
||||
)
|
||||
}
|
||||
|
||||
// ArchiveExportFileName returns the name a database target's archive
|
||||
// ExportFileName 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(
|
||||
// made safe as in FileName and the time in UTC.
|
||||
func ExportFileName(
|
||||
webhookName, targetName string, at time.Time,
|
||||
) string {
|
||||
return "archive-" + archiveNamePart(webhookName) + "-" +
|
||||
@@ -54,7 +54,7 @@ func ArchiveExportFileName(
|
||||
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
||||
}
|
||||
|
||||
// ArchiveExport is a database target's archive listed for download. It
|
||||
// Export is a database target's archive listed for download. It
|
||||
// opens one of the target's files at a time, only when its rows are
|
||||
// about to be written out, and closes it before it opens the next, so
|
||||
// an export holds at most one file open however many the target has.
|
||||
@@ -66,7 +66,7 @@ func ArchiveExportFileName(
|
||||
// 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 {
|
||||
type Export struct {
|
||||
// periods are the periods of the target's files when the export
|
||||
// was listed, "" for the file without one, in the order
|
||||
// archiveFiles lists them.
|
||||
@@ -77,7 +77,7 @@ type ArchiveExport struct {
|
||||
// between.
|
||||
lock sync.Locker
|
||||
|
||||
// currentPath returns the path ArchivePath gives the target under
|
||||
// currentPath returns the path Path gives the target under
|
||||
// the names stored for it now, which a rename may have changed since
|
||||
// the export was listed.
|
||||
currentPath func() (string, error)
|
||||
@@ -103,11 +103,12 @@ type exportedName struct {
|
||||
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
|
||||
// NewExport lists a database target's archive files for export,
|
||||
// given the path Path gives it (see archiveFiles). It opens none
|
||||
// of them. Its caller holds lock, which every rename of the target's
|
||||
// files runs under, from reading the names path is made of until it
|
||||
// returns, so the files it lists are the ones those names give.
|
||||
// files runs under (Writers.RenameLock returns it), from reading the names
|
||||
// path is made of until it returns, so the files it lists are the ones
|
||||
// those names give.
|
||||
//
|
||||
// WriteGzipJSON, called without lock held, finds each file again by its
|
||||
// period under the path currentPath gives, holding lock while it does
|
||||
@@ -115,18 +116,18 @@ type exportedName struct {
|
||||
// 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(
|
||||
func NewExport(
|
||||
path string,
|
||||
lock sync.Locker,
|
||||
currentPath func() (string, error),
|
||||
log *slog.Logger,
|
||||
) (*ArchiveExport, error) {
|
||||
) (*Export, error) {
|
||||
files, err := archiveFiles(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
|
||||
x := &Export{lock: lock, currentPath: currentPath, log: log}
|
||||
|
||||
for _, file := range files {
|
||||
x.periods = append(x.periods, file.period)
|
||||
@@ -199,7 +200,7 @@ func openExportFile(
|
||||
// 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(
|
||||
func (x *Export) WriteGzipJSON(
|
||||
ctx context.Context,
|
||||
w io.Writer,
|
||||
webhook *database.Webhook,
|
||||
@@ -228,7 +229,7 @@ func (x *ArchiveExport) WriteGzipJSON(
|
||||
// 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(
|
||||
func (x *Export) openFile(
|
||||
ctx context.Context, period string,
|
||||
) (*exportFile, error) {
|
||||
x.lock.Lock()
|
||||
@@ -258,7 +259,7 @@ func (f *exportFile) close() error {
|
||||
|
||||
// writeJSON writes head with archived_events added as its last key,
|
||||
// the rows going into it one at a time.
|
||||
func (x *ArchiveExport) writeJSON(
|
||||
func (x *Export) writeJSON(
|
||||
ctx context.Context, w io.Writer, head []byte,
|
||||
) error {
|
||||
// head goes out without its closing brace, so that
|
||||
@@ -286,7 +287,7 @@ func (x *ArchiveExport) writeJSON(
|
||||
// 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 {
|
||||
func (x *Export) writeRows(ctx context.Context, w io.Writer) error {
|
||||
sep := "\n"
|
||||
|
||||
for _, period := range x.periods {
|
||||
@@ -360,6 +361,7 @@ func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
||||
"webhook_id": ev.WebhookID,
|
||||
"entrypoint_id": ev.EntrypointID,
|
||||
"method": ev.Method,
|
||||
"raw_query": ev.RawQuery,
|
||||
"headers": ev.Headers,
|
||||
"body": ev.Body,
|
||||
"content_type": ev.ContentType,
|
||||
+41
-31
@@ -1,4 +1,4 @@
|
||||
package delivery_test
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
@@ -19,8 +19,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// The webhook and the target the export tests' archives belong to.
|
||||
@@ -37,7 +37,7 @@ 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,
|
||||
t *testing.T, export *archive.Export, w io.Writer,
|
||||
) error {
|
||||
t.Helper()
|
||||
|
||||
@@ -57,7 +57,7 @@ func writeExportTo(
|
||||
|
||||
// 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 {
|
||||
func listExport(t *testing.T, path string) *archive.Export {
|
||||
t.Helper()
|
||||
|
||||
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
||||
@@ -73,10 +73,10 @@ func newExport(
|
||||
path string,
|
||||
lock sync.Locker,
|
||||
currentPath func() (string, error),
|
||||
) *delivery.ArchiveExport {
|
||||
) *archive.Export {
|
||||
t.Helper()
|
||||
|
||||
export, err := delivery.NewArchiveExport(
|
||||
export, err := archive.NewExport(
|
||||
path, lock, currentPath, archiveTestLogger(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -96,7 +96,7 @@ func exportArchive(t *testing.T, path string) map[string]any {
|
||||
// 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,
|
||||
t *testing.T, export *archive.Export,
|
||||
) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
@@ -157,22 +157,23 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive.db")
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
|
||||
|
||||
for i, body := range bodies {
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
||||
require.NoError(t, w.Write(archive.ExportArchivedEvent{
|
||||
EventID: fmt.Sprintf("ev-%d", i),
|
||||
WebhookID: exportWebhookID,
|
||||
EntrypointID: "ep-1",
|
||||
Method: "POST",
|
||||
RawQuery: eventQuery,
|
||||
Headers: `{"X-Test":["yes"]}`,
|
||||
Body: body,
|
||||
ContentType: testContentType,
|
||||
}, 0))
|
||||
}
|
||||
|
||||
var stored []delivery.ExportArchivedEvent
|
||||
var stored []archive.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, openArchiveDBForRead(t, path).
|
||||
Order("id").Find(&stored).Error)
|
||||
@@ -200,7 +201,7 @@ func TestArchiveExport_MatchesStoredRows(t *testing.T) {
|
||||
// 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 *testing.T, row archive.ExportArchivedEvent, ev map[string]any,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
@@ -215,12 +216,13 @@ func assertExportedRow(
|
||||
assert.Equal(t, row.WebhookID, ev["webhook_id"])
|
||||
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
|
||||
assert.Equal(t, row.Method, ev["method"])
|
||||
assert.Equal(t, row.RawQuery, ev["raw_query"])
|
||||
assert.Equal(t, row.Headers, ev["headers"])
|
||||
assert.Equal(t, row.ContentType, ev["content_type"])
|
||||
|
||||
if row.Body != binaryBody {
|
||||
assert.Equal(t, row.Body, ev["body"])
|
||||
assert.Len(t, ev, 9, "the nine columns and nothing else: %v", ev)
|
||||
assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -229,7 +231,7 @@ func assertExportedRow(
|
||||
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)
|
||||
assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev)
|
||||
}
|
||||
|
||||
// TestArchiveExport_Empty proves an archive with nothing in it exports
|
||||
@@ -246,7 +248,7 @@ func TestArchiveExport_Empty(t *testing.T) {
|
||||
|
||||
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
|
||||
require.NoError(t,
|
||||
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
|
||||
archive.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
|
||||
Open(0),
|
||||
)
|
||||
|
||||
@@ -282,18 +284,18 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive.db")
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
|
||||
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "listed"}, 0))
|
||||
|
||||
opened := &unlockHook{fn: func() {
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
||||
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "during"}, 0))
|
||||
}}
|
||||
export := newExport(t, path, opened, func() (string, error) {
|
||||
return path, nil
|
||||
})
|
||||
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
|
||||
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "before-open"}, 0))
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"listed", "before-open"},
|
||||
@@ -303,7 +305,7 @@ func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
||||
var stored int64
|
||||
|
||||
require.NoError(t, openArchiveDBForRead(t, path).
|
||||
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
||||
Model(&archive.ExportArchivedEvent{}).Count(&stored).Error)
|
||||
assert.Equal(t, int64(3), stored)
|
||||
}
|
||||
|
||||
@@ -316,11 +318,11 @@ func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
|
||||
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "archive-old.db")
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
|
||||
for _, period := range []string{"", dayPeriod, hourPeriod} {
|
||||
require.NoError(t, w.WritePeriod(
|
||||
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||
))
|
||||
}
|
||||
|
||||
@@ -351,19 +353,19 @@ func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
|
||||
// Written in an order that is not the export's.
|
||||
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
|
||||
require.NoError(t, w.WritePeriod(
|
||||
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||
))
|
||||
}
|
||||
|
||||
export := listExport(t, path)
|
||||
|
||||
require.NoError(t, w.WritePeriod(
|
||||
delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
|
||||
archive.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
|
||||
))
|
||||
|
||||
events := exportedEvents(t, writeExport(t, export))
|
||||
@@ -432,13 +434,13 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
|
||||
path := filepath.Join(dir, "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
random := make([]byte, 36<<10)
|
||||
|
||||
for hour := range 24 {
|
||||
_, _ = rand.Read(random)
|
||||
|
||||
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
|
||||
require.NoError(t, w.WritePeriod(archive.ExportArchivedEvent{
|
||||
Body: base64.StdEncoding.EncodeToString(random),
|
||||
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
|
||||
}
|
||||
@@ -458,8 +460,13 @@ func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// the largest heap it saw at a write. It collects garbage twice before
|
||||
// each reading, so the heap it reads is what is still held. Once is not
|
||||
// enough: the libraries the export calls (regexp, under GORM's table
|
||||
// names, and encoding/json among them) cache buffers in a sync.Pool,
|
||||
// which keeps them through one collection, so after one the reading
|
||||
// counts however many happen to be cached. That varies from run to run
|
||||
// by about as much as the limit in TestArchiveExport_Streams.
|
||||
type heapPeak struct {
|
||||
max uint64
|
||||
}
|
||||
@@ -467,6 +474,7 @@ type heapPeak struct {
|
||||
func (p *heapPeak) Write(b []byte) (int, error) {
|
||||
var m runtime.MemStats
|
||||
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
runtime.ReadMemStats(&m)
|
||||
p.max = max(p.max, m.HeapAlloc)
|
||||
@@ -481,7 +489,7 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive.db")
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
|
||||
// Base64 makes four characters of every three bytes.
|
||||
random := make([]byte, bodySize/4*3)
|
||||
@@ -489,13 +497,15 @@ func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
||||
for range rows {
|
||||
_, _ = rand.Read(random)
|
||||
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
||||
require.NoError(t, w.Write(archive.ExportArchivedEvent{
|
||||
Body: base64.StdEncoding.EncodeToString(random),
|
||||
}, 0))
|
||||
}
|
||||
|
||||
export := listExport(t, path)
|
||||
|
||||
// Twice, for the reason heapPeak gives.
|
||||
runtime.GC()
|
||||
runtime.GC()
|
||||
|
||||
var start runtime.MemStats
|
||||
@@ -555,7 +565,7 @@ func TestArchiveExportFileName(t *testing.T) {
|
||||
|
||||
assert.Equal(t,
|
||||
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
|
||||
delivery.ArchiveExportFileName(
|
||||
archive.ExportFileName(
|
||||
exportWebhookName, exportTargetName,
|
||||
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
|
||||
),
|
||||
@@ -1,7 +1,6 @@
|
||||
package delivery_test
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -13,8 +12,8 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// deliverTo archives one event to a database target, leaving the
|
||||
@@ -24,12 +23,9 @@ func deliverTo(
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
event := newEvent(`{"archived":true}`)
|
||||
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
)
|
||||
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
|
||||
}
|
||||
|
||||
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
|
||||
@@ -41,28 +37,28 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
first := env.seedDatabaseTarget(t, "")
|
||||
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
||||
other := env.seedDatabaseTarget(t, "")
|
||||
first := env.seedDatabaseTarget(t, 0, "")
|
||||
second := env.addDatabaseTarget(t, first.WebhookID, 0, "")
|
||||
other := env.seedDatabaseTarget(t, 0, "")
|
||||
|
||||
for _, tgt := range []*database.Target{first, second, other} {
|
||||
deliverTo(t, env, tgt)
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
|
||||
"the writer should hold an open handle after a write",
|
||||
)
|
||||
}
|
||||
|
||||
env.eng.EvictWebhook(first.WebhookID)
|
||||
env.writers.EvictWebhook(first.WebhookID)
|
||||
|
||||
for _, tgt := range []*database.Target{first, second} {
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"eviction should remove the registry entry",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
|
||||
"eviction should close the archive handle",
|
||||
)
|
||||
assert.FileExists(
|
||||
@@ -72,7 +68,7 @@ func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(other.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(other.ID),
|
||||
"another webhook's writer must be left alone",
|
||||
)
|
||||
}
|
||||
@@ -84,21 +80,21 @@ func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
doomed := env.seedDatabaseTarget(t, "")
|
||||
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
|
||||
doomed := env.seedDatabaseTarget(t, 0, "")
|
||||
kept := env.addDatabaseTarget(t, doomed.WebhookID, 0, "")
|
||||
|
||||
deliverTo(t, env, doomed)
|
||||
deliverTo(t, env, kept)
|
||||
|
||||
env.eng.EvictTarget(doomed.ID)
|
||||
env.writers.EvictTarget(doomed.ID)
|
||||
|
||||
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
|
||||
assert.False(t, env.writers.ExportHasArchiveWriter(doomed.ID))
|
||||
assert.FileExists(
|
||||
t, env.archivePath(doomed),
|
||||
"eviction must not delete the archive file",
|
||||
)
|
||||
assert.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(kept.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(kept.ID),
|
||||
"the other target's writer must be left alone",
|
||||
)
|
||||
}
|
||||
@@ -112,21 +108,21 @@ func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
assert.NotPanics(t, func() {
|
||||
env.eng.EvictWebhook("no-such-webhook")
|
||||
env.eng.EvictWebhook("no-such-webhook")
|
||||
env.eng.EvictTarget("no-such-target")
|
||||
env.eng.EvictTarget("no-such-target")
|
||||
env.writers.EvictWebhook("no-such-webhook")
|
||||
env.writers.EvictWebhook("no-such-webhook")
|
||||
env.writers.EvictTarget("no-such-target")
|
||||
env.writers.EvictTarget("no-such-target")
|
||||
})
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter("no-such-target"),
|
||||
t, env.writers.ExportHasArchiveWriter("no-such-target"),
|
||||
"eviction must not create a writer",
|
||||
)
|
||||
}
|
||||
|
||||
// evictTestRow builds an archive row for the eviction tests.
|
||||
func evictTestRow(eventID string) delivery.ExportArchivedEvent {
|
||||
return delivery.ExportArchivedEvent{
|
||||
func evictTestRow(eventID string) archive.ExportArchivedEvent {
|
||||
return archive.ExportArchivedEvent{
|
||||
EventID: eventID,
|
||||
WebhookID: "wh-evict",
|
||||
Method: http.MethodPost,
|
||||
@@ -147,7 +143,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-evicted.db")
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
@@ -164,7 +160,7 @@ func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
|
||||
err := w.Write(evictTestRow("ev-2"), 0)
|
||||
|
||||
require.ErrorIs(
|
||||
t, err, delivery.ErrExportArchiveWriterEvicted,
|
||||
t, err, archive.ErrExportArchiveWriterEvicted,
|
||||
"an evicted writer must refuse writes",
|
||||
)
|
||||
assert.NoFileExists(
|
||||
@@ -187,7 +183,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-evicted.db")
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
@@ -199,7 +195,7 @@ func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
|
||||
err := w.SweepExpired(time.Hour)
|
||||
|
||||
require.ErrorIs(
|
||||
t, err, delivery.ErrExportArchiveWriterEvicted,
|
||||
t, err, archive.ErrExportArchiveWriterEvicted,
|
||||
"an evicted writer must refuse an idle sweep",
|
||||
)
|
||||
assert.False(
|
||||
@@ -230,7 +226,7 @@ const racingWriteGoroutines = 4
|
||||
// against a hang, not a timing assumption: the first write after
|
||||
// the eviction is refused.
|
||||
func startRacingWrites(
|
||||
w *delivery.ExportArchiveWriter,
|
||||
w *archive.ExportArchiveWriter,
|
||||
) *racingWrites {
|
||||
r := &racingWrites{
|
||||
started: make(chan struct{}, racingWriteGoroutines),
|
||||
@@ -276,7 +272,7 @@ func (r *racingWrites) record(err error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if errors.Is(err, delivery.ErrExportArchiveWriterEvicted) {
|
||||
if errors.Is(err, archive.ErrExportArchiveWriterEvicted) {
|
||||
r.sawEvicted = true
|
||||
|
||||
return
|
||||
@@ -314,13 +310,13 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
|
||||
// Prime the registry so the test can hold the very writer the
|
||||
// eviction is about to detach.
|
||||
deliverTo(t, env, tgt)
|
||||
|
||||
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||
w := env.writers.ExportArchiveWriterFor(tgt.ID)
|
||||
require.NotNil(t, w)
|
||||
require.True(t, w.HandleOpen())
|
||||
|
||||
@@ -330,7 +326,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
||||
// eviction has to contend for the writer's mutex.
|
||||
race.awaitFirstWrite()
|
||||
|
||||
env.eng.EvictWebhook(tgt.WebhookID)
|
||||
env.writers.EvictWebhook(tgt.WebhookID)
|
||||
|
||||
sawEvicted, otherErr := race.wait()
|
||||
|
||||
@@ -345,7 +341,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
|
||||
"been evicted",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"the registry entry must stay gone",
|
||||
)
|
||||
}
|
||||
@@ -359,45 +355,45 @@ func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
|
||||
deliverTo(t, env, tgt)
|
||||
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
require.True(t, env.writers.ExportHasArchiveWriter(tgt.ID))
|
||||
|
||||
env.eng.EvictWebhook(tgt.WebhookID)
|
||||
env.writers.EvictWebhook(tgt.WebhookID)
|
||||
|
||||
// A fresh delivery for the same target gets a brand new
|
||||
// writer from the registry, so archiving keeps working.
|
||||
deliverTo(t, env, tgt)
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"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) {
|
||||
// TestEvictAll_WriteAfterIsRefused proves that EvictAll, which the
|
||||
// delivery engine calls when it stops, closes each archive writer the
|
||||
// way deleting its webhook does: a write that reaches a writer after
|
||||
// it is refused, reopens nothing and adds no row.
|
||||
func TestEvictAll_WriteAfterIsRefused(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
|
||||
deliverTo(t, env, tgt)
|
||||
|
||||
w := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||
w := env.writers.ExportArchiveWriterFor(tgt.ID)
|
||||
require.NotNil(t, w)
|
||||
require.True(t, w.HandleOpen())
|
||||
|
||||
require.NoError(t, env.eng.ExportStop(context.Background()))
|
||||
env.writers.EvictAll()
|
||||
|
||||
err := w.Write(evictTestRow("ev-after-stop"), 0)
|
||||
|
||||
require.ErrorIs(
|
||||
t, err, delivery.ErrExportArchiveWriterEvicted,
|
||||
t, err, archive.ErrExportArchiveWriterEvicted,
|
||||
"a write after the stop must be refused",
|
||||
)
|
||||
assert.False(
|
||||
@@ -405,7 +401,7 @@ func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
|
||||
"a refused write must not reopen the archive",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"the stop should empty the registry",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
package archive
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
|
||||
// an evicted archive writer. It carries the Err prefix rather
|
||||
// than this file's usual Export one because it is a sentinel
|
||||
// error.
|
||||
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
|
||||
|
||||
// ExportArchivedEvent aliases the archive row type so black-box
|
||||
// tests can construct and read archive rows.
|
||||
type ExportArchivedEvent = archivedEvent
|
||||
|
||||
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
||||
// can exercise the archive file mechanics.
|
||||
type ExportArchiveWriter struct {
|
||||
w *archiveWriter
|
||||
}
|
||||
|
||||
// NewExportArchiveWriter builds an archive writer for tests,
|
||||
// optionally overriding the reopen debounce (a non-positive
|
||||
// debounce keeps the production default).
|
||||
func NewExportArchiveWriter(
|
||||
path string, log *slog.Logger, debounce time.Duration,
|
||||
) *ExportArchiveWriter {
|
||||
w := newArchiveWriter(path, log)
|
||||
if debounce > 0 {
|
||||
w.debounce = debounce
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}
|
||||
}
|
||||
|
||||
// Write archives a row through the writer, into the file named
|
||||
// without a period.
|
||||
func (e *ExportArchiveWriter) Write(
|
||||
row ExportArchivedEvent, expiry time.Duration,
|
||||
) error {
|
||||
return e.w.write(row, expiry, "")
|
||||
}
|
||||
|
||||
// WritePeriod archives a row through the writer, into the file for
|
||||
// period.
|
||||
func (e *ExportArchiveWriter) WritePeriod(
|
||||
row ExportArchivedEvent, expiry time.Duration, period string,
|
||||
) error {
|
||||
return e.w.write(row, expiry, period)
|
||||
}
|
||||
|
||||
// Open opens the archive file, pruning when expiry is positive.
|
||||
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
||||
return e.w.open(e.w.path, expiry)
|
||||
}
|
||||
|
||||
// Reopen closes and reopens the archive file.
|
||||
func (e *ExportArchiveWriter) Reopen(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.reopen(e.w.path, expiry)
|
||||
}
|
||||
|
||||
// SetNow replaces the clock the writer measures its reopen
|
||||
// debounce on.
|
||||
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
|
||||
e.w.now = now
|
||||
}
|
||||
|
||||
// Reopens reports how many times the file has been opened.
|
||||
func (e *ExportArchiveWriter) Reopens() int {
|
||||
return e.w.reopens
|
||||
}
|
||||
|
||||
// DB returns the writer's current open handle for row
|
||||
// inspection in tests.
|
||||
func (e *ExportArchiveWriter) DB() *gorm.DB {
|
||||
return e.w.db
|
||||
}
|
||||
|
||||
// Path returns the archive file the writer owns.
|
||||
func (e *ExportArchiveWriter) Path() string {
|
||||
return e.w.path
|
||||
}
|
||||
|
||||
// OpenExisting opens the archive without permitting creation,
|
||||
// the way the idle sweep does.
|
||||
func (e *ExportArchiveWriter) OpenExisting(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
|
||||
}
|
||||
|
||||
// SweepExpired runs an idle sweep of the archive.
|
||||
func (e *ExportArchiveWriter) SweepExpired(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.sweepExpired(expiry)
|
||||
}
|
||||
|
||||
// Evict marks the writer evicted and closes its handle, exactly
|
||||
// as leaving the registry does.
|
||||
func (e *ExportArchiveWriter) Evict() {
|
||||
e.w.evict()
|
||||
}
|
||||
|
||||
// Rename gives the archive file a new name in the same directory,
|
||||
// as a rename of the webhook or target does.
|
||||
func (e *ExportArchiveWriter) Rename(name string) error {
|
||||
return e.w.rename(name)
|
||||
}
|
||||
|
||||
// HandleOpen reports whether the writer currently holds an open
|
||||
// archive handle.
|
||||
func (e *ExportArchiveWriter) HandleOpen() bool {
|
||||
e.w.mu.Lock()
|
||||
defer e.w.mu.Unlock()
|
||||
|
||||
return e.w.db != nil
|
||||
}
|
||||
|
||||
// Same reports whether both wrappers refer to the very same
|
||||
// underlying archive writer, so a test can prove a registry entry
|
||||
// is the writer it was handed rather than a replacement.
|
||||
func (e *ExportArchiveWriter) Same(
|
||||
other *ExportArchiveWriter,
|
||||
) bool {
|
||||
return other != nil && e.w == other.w
|
||||
}
|
||||
|
||||
// ExportArchiveWriterFor returns the archive writer the registry
|
||||
// currently caches for a database target, or nil when none is
|
||||
// cached. It never creates one, so a test can hold a reference to
|
||||
// the very writer an eviction is about to detach.
|
||||
func (ws *Writers) ExportArchiveWriterFor(
|
||||
targetID string,
|
||||
) *ExportArchiveWriter {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
w, ok := ws.writers[targetID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}
|
||||
}
|
||||
|
||||
// ExportHasArchiveWriter reports whether the registry currently
|
||||
// caches an archive writer for a target.
|
||||
func (ws *Writers) ExportHasArchiveWriter(
|
||||
targetID string,
|
||||
) bool {
|
||||
ws.mu.Lock()
|
||||
defer ws.mu.Unlock()
|
||||
|
||||
_, ok := ws.writers[targetID]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// ExportArchiveHandleOpen reports whether the cached archive
|
||||
// writer for a target holds an open database handle. It
|
||||
// returns false when no writer is cached.
|
||||
func (ws *Writers) ExportArchiveHandleOpen(
|
||||
targetID string,
|
||||
) bool {
|
||||
ws.mu.Lock()
|
||||
w, ok := ws.writers[targetID]
|
||||
ws.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
return w.db != nil
|
||||
}
|
||||
|
||||
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
||||
// archive file path of the cached writer for a target, so a
|
||||
// test can prime the registry the way a delivery would.
|
||||
func (ws *Writers) ExportEnsureArchiveWriter(
|
||||
targetID string,
|
||||
) (string, error) {
|
||||
w, err := ws.writerFor(targetID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return w.path, nil
|
||||
}
|
||||
|
||||
// ExportSweepWriterFor takes a target's registry writer exactly
|
||||
// as the idle sweep does, reporting whether the sweep had to
|
||||
// create the entry. It lets a test drive the registry through the
|
||||
// sweep's own entry point instead of choreographing goroutines.
|
||||
func (ws *Writers) ExportSweepWriterFor(
|
||||
targetID string,
|
||||
) (*ExportArchiveWriter, bool, error) {
|
||||
w, created, err := ws.sweepWriterFor(targetID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}, created, nil
|
||||
}
|
||||
|
||||
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
||||
// exactly as a finished sweep does.
|
||||
func (ws *Writers) ExportReleaseSweepWriter(
|
||||
targetID string, w *ExportArchiveWriter,
|
||||
) {
|
||||
ws.releaseSweepWriter(targetID, w.w)
|
||||
}
|
||||
|
||||
// NewTestSweeper builds a Sweeper backed by the given main
|
||||
// database and archive writers, without the fx lifecycle.
|
||||
// Intended for tests.
|
||||
func NewTestSweeper(
|
||||
db *database.Database,
|
||||
writers *Writers,
|
||||
log *slog.Logger,
|
||||
) *Sweeper {
|
||||
return &Sweeper{
|
||||
db: db,
|
||||
writers: writers,
|
||||
log: log,
|
||||
interval: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportSweep runs a single archive sweep synchronously for
|
||||
// tests.
|
||||
func (s *Sweeper) ExportSweep(ctx context.Context) {
|
||||
s.sweep(ctx)
|
||||
}
|
||||
|
||||
// ExportStart starts the sweeper's background loop for tests.
|
||||
func (s *Sweeper) ExportStart() {
|
||||
s.start()
|
||||
}
|
||||
|
||||
// ExportRegisterHooks registers the sweeper's real fx lifecycle
|
||||
// hooks on a lifecycle supplied by a test, so a test can drive
|
||||
// the exact OnStart/OnStop functions the application runs and
|
||||
// hand OnStart the kind of context fx actually supplies.
|
||||
func (s *Sweeper) ExportRegisterHooks(lc fx.Lifecycle) {
|
||||
s.registerHooks(lc)
|
||||
}
|
||||
|
||||
// ExportStop stops the sweeper's background loop for tests.
|
||||
func (s *Sweeper) ExportStop(ctx context.Context) error {
|
||||
return s.stop(ctx)
|
||||
}
|
||||
|
||||
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
|
||||
// that never observes cancellation and returns only when release
|
||||
// is closed. It stands in for a prune stuck on a locked archive.
|
||||
func (s *Sweeper) ExportWedgeLoop(
|
||||
release <-chan struct{},
|
||||
) {
|
||||
s.wg.Go(func() {
|
||||
<-release
|
||||
})
|
||||
}
|
||||
|
||||
// ExportSetInterval overrides the sweep interval for tests.
|
||||
func (s *Sweeper) ExportSetInterval(d time.Duration) {
|
||||
s.interval = d
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
package delivery
|
||||
package archive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -41,10 +40,10 @@ type archiveFile struct {
|
||||
period string
|
||||
}
|
||||
|
||||
// ValidateArchiveRotation checks a user-supplied archive rotation for
|
||||
// ValidateRotation 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 {
|
||||
func ValidateRotation(rotation string) error {
|
||||
switch rotation {
|
||||
case "", archiveRotationNone, archiveRotationMonthly,
|
||||
archiveRotationDaily, archiveRotationHourly:
|
||||
@@ -54,35 +53,10 @@ func ValidateArchiveRotation(rotation string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none
|
||||
// or an empty rotation.
|
||||
func archivePeriod(rotation string, receivedAt time.Time) string {
|
||||
switch rotation {
|
||||
case archiveRotationMonthly:
|
||||
@@ -97,7 +71,7 @@ func archivePeriod(rotation string, receivedAt time.Time) string {
|
||||
}
|
||||
|
||||
// archivePeriodPath returns the path of a database target's archive
|
||||
// file for a period: path, as ArchivePath gives it, with "-" and the
|
||||
// file for a period: path, as Path gives it, with "-" and the
|
||||
// period put before its ".db". The period "" gives path itself.
|
||||
func archivePeriodPath(path, period string) string {
|
||||
if period == "" {
|
||||
@@ -107,28 +81,23 @@ func archivePeriodPath(path, period string) string {
|
||||
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
|
||||
// PathAt returns the archive file a database target writes an
|
||||
// event received at receivedAt to: Path's file, with the
|
||||
// period in its name when the target rotates.
|
||||
func ArchivePathAt(
|
||||
func PathAt(
|
||||
dbMgr *database.WebhookDBManager,
|
||||
webhook *database.Webhook,
|
||||
target *database.Target,
|
||||
receivedAt time.Time,
|
||||
) (string, error) {
|
||||
rotation, err := parseArchiveRotation(target.Config)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
) string {
|
||||
return archivePeriodPath(
|
||||
ArchivePath(dbMgr, webhook, target),
|
||||
archivePeriod(rotation, receivedAt),
|
||||
), nil
|
||||
Path(dbMgr, webhook, target),
|
||||
archivePeriod(target.ArchiveRotation, receivedAt),
|
||||
)
|
||||
}
|
||||
|
||||
// archiveFiles lists the database target's archive files that exist,
|
||||
// given the path ArchivePath gives it: the file at path, then each
|
||||
// given the path Path gives it: the file at path, then each
|
||||
// file archivePeriodPath names for path and a period, oldest period
|
||||
// first. Which rotation wrote a file does not matter, so the files of
|
||||
// an earlier rotation setting are listed too.
|
||||
+44
-58
@@ -1,4 +1,4 @@
|
||||
package delivery_test
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -9,20 +9,17 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
)
|
||||
|
||||
// The archive rotations, and the configs of a daily target and of one
|
||||
// whose rotation is not one of the four.
|
||||
// The archive rotations.
|
||||
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
|
||||
@@ -39,32 +36,30 @@ 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.
|
||||
// deliverReceivedAt archives, as a delivery to a database target
|
||||
// does, an event whose receive time is receivedAt, and returns the
|
||||
// event's id. The receive time is what decides a rotated archive's
|
||||
// file, so setting it is how these tests move the clock across a
|
||||
// period boundary.
|
||||
func (env *archiveEnv) deliverReceivedAt(
|
||||
t *testing.T, tgt *database.Target, receivedAt time.Time,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
event := newEvent(`{"n":1}`)
|
||||
event.CreatedAt = receivedAt
|
||||
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
)
|
||||
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
|
||||
|
||||
return event.ID
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
|
||||
// TestWrite_RotatesAtEachPeriodBoundary writes, for each
|
||||
// rotation, an event received in the last second of a period and one
|
||||
// received in the first second of the next, and checks each lands in
|
||||
// the file named for its own period, in UTC. Rotation none keeps both
|
||||
// in the one file.
|
||||
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||
func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
berlin := time.FixedZone("CEST", 2*60*60)
|
||||
@@ -109,7 +104,7 @@ func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
|
||||
tgt := env.seedDatabaseTarget(t, 0, tc.rotation)
|
||||
path := env.archivePath(tgt)
|
||||
|
||||
first := env.deliverReceivedAt(t, tgt, tc.before)
|
||||
@@ -128,7 +123,7 @@ func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
|
||||
tgt := env.seedDatabaseTarget(t, 0, rotationNone)
|
||||
|
||||
first := env.deliverReceivedAt(t, tgt, cases[0].before)
|
||||
second := env.deliverReceivedAt(t, tgt, cases[0].after)
|
||||
@@ -138,14 +133,14 @@ func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
|
||||
// TestWrite_EventWithoutReceiveTime proves an event whose
|
||||
// receive time is not known goes to the file for the time it is
|
||||
// archived, rather than to one for the year 1.
|
||||
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
|
||||
func TestWrite_EventWithoutReceiveTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
|
||||
tgt := env.seedDatabaseTarget(t, 0, rotationMonthly)
|
||||
path := env.archivePath(tgt)
|
||||
|
||||
before := time.Now().UTC().Format("2006-01")
|
||||
@@ -163,20 +158,20 @@ func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
|
||||
assert.NoFileExists(t, periodPath(path, "0001-01"))
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
|
||||
// TestWrite_RotationChangeKeepsOldFiles changes a target's
|
||||
// rotation from none to daily between two events, and checks the
|
||||
// second goes to the daily file while the first stays where it was.
|
||||
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
|
||||
func TestWrite_RotationChangeKeepsOldFiles(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
tgt := env.seedDatabaseTarget(t, 0, rotationNone)
|
||||
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
|
||||
tgt.ArchiveRotation = rotationDaily
|
||||
second := env.deliverReceivedAt(t, tgt, at)
|
||||
|
||||
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
|
||||
@@ -194,9 +189,7 @@ func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(
|
||||
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
|
||||
)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, rotationDaily)
|
||||
path := env.archivePath(tgt)
|
||||
emptied := periodPath(path, dayPeriod)
|
||||
kept := periodPath(path, nextDayPeriod)
|
||||
@@ -226,17 +219,17 @@ func TestRename_MovesEveryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
tgt := env.seedDatabaseTarget(t, 0, rotationNone)
|
||||
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
|
||||
tgt.ArchiveRotation = rotationDaily
|
||||
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"))
|
||||
require.NoError(t, env.writers.Rename(tgt.ID, "Orders", "Long Term"))
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
@@ -268,7 +261,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, dailyConfig)
|
||||
tgt := env.seedDatabaseTarget(t, 0, rotationDaily)
|
||||
oldPath := env.archivePath(tgt)
|
||||
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -282,8 +275,8 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
||||
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
|
||||
|
||||
require.ErrorIs(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
delivery.ErrArchiveNameTaken,
|
||||
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
archive.ErrNameTaken,
|
||||
)
|
||||
|
||||
assert.FileExists(t, periodPath(oldPath, dayPeriod))
|
||||
@@ -291,7 +284,7 @@ func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
||||
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
|
||||
}
|
||||
|
||||
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
|
||||
// TestStatArchive_EveryFile proves Stat counts and adds up every
|
||||
// one of a target's files, takes the latest write of any of them, and
|
||||
// leaves out files whose names only look like the target's.
|
||||
func TestStatArchive_EveryFile(t *testing.T) {
|
||||
@@ -323,7 +316,7 @@ func TestStatArchive_EveryFile(t *testing.T) {
|
||||
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
|
||||
}
|
||||
|
||||
got, err := delivery.StatArchive(path)
|
||||
got, err := archive.Stat(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, len(files), got.Files)
|
||||
assert.Equal(t, size, got.Size)
|
||||
@@ -335,41 +328,34 @@ func TestArchivePathAt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
dbMgr := database.NewTestWebhookDBManager(dataDir)
|
||||
dbMgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||
webhook := &database.Webhook{
|
||||
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
|
||||
}
|
||||
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
|
||||
|
||||
cases := map[string]string{
|
||||
"": "",
|
||||
`{"rotation":"` + rotationNone + `"}`: "",
|
||||
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
|
||||
dailyConfig: "-2026-10-01",
|
||||
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
|
||||
"": "",
|
||||
rotationNone: "",
|
||||
rotationMonthly: "-2026-10",
|
||||
rotationDaily: "-2026-10-01",
|
||||
rotationHourly: "-2026-10-01-19",
|
||||
}
|
||||
|
||||
for config, period := range cases {
|
||||
for rotation, period := range cases {
|
||||
target := &database.Target{
|
||||
BaseModel: database.BaseModel{ID: "tgt-id"},
|
||||
Name: "Archive",
|
||||
Config: config,
|
||||
BaseModel: database.BaseModel{ID: "tgt-id"},
|
||||
Name: "Archive",
|
||||
ArchiveRotation: rotation,
|
||||
}
|
||||
|
||||
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,
|
||||
archive.PathAt(dbMgr, webhook, target, at), rotation,
|
||||
)
|
||||
}
|
||||
|
||||
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
|
||||
Config: weeklyConfig,
|
||||
}, at)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestValidateArchiveRotation accepts the four rotations, and empty,
|
||||
@@ -380,10 +366,10 @@ func TestValidateArchiveRotation(t *testing.T) {
|
||||
for _, ok := range []string{
|
||||
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
||||
} {
|
||||
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
|
||||
require.NoError(t, archive.ValidateRotation(ok), ok)
|
||||
}
|
||||
|
||||
for _, bad := range []string{"weekly", "Daily", "hourly "} {
|
||||
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
|
||||
require.Error(t, archive.ValidateRotation(bad), bad)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package delivery
|
||||
package archive
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -15,18 +15,18 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
// ArchiveSweeperParams holds the fx dependencies for the
|
||||
// ArchiveSweeper.
|
||||
type ArchiveSweeperParams struct {
|
||||
// SweeperParams holds the fx dependencies for the
|
||||
// Sweeper.
|
||||
type SweeperParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Database *database.Database
|
||||
Engine *Engine
|
||||
Writers *Writers
|
||||
Logger *logger.Logger
|
||||
}
|
||||
|
||||
// ArchiveSweeper periodically prunes expired rows from the
|
||||
// Sweeper periodically prunes expired rows from the
|
||||
// archive databases of database targets that carry a positive
|
||||
// expiry.
|
||||
//
|
||||
@@ -40,9 +40,9 @@ type ArchiveSweeperParams struct {
|
||||
// It reuses Config.RetentionSweepInterval rather than
|
||||
// introducing a second interval: this is a retention sweep with
|
||||
// the same semantics as the event retention reaper.
|
||||
type ArchiveSweeper struct {
|
||||
type Sweeper struct {
|
||||
db *database.Database
|
||||
eng *Engine
|
||||
writers *Writers
|
||||
log *slog.Logger
|
||||
interval time.Duration
|
||||
|
||||
@@ -54,16 +54,16 @@ type ArchiveSweeper struct {
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewArchiveSweeper creates the archive sweeper and registers
|
||||
// NewSweeper creates the archive sweeper and registers
|
||||
// its fx lifecycle hooks. The background sweep loop starts on
|
||||
// OnStart and stops cleanly on OnStop via context cancellation.
|
||||
func NewArchiveSweeper(
|
||||
func NewSweeper(
|
||||
lc fx.Lifecycle,
|
||||
params ArchiveSweeperParams,
|
||||
) *ArchiveSweeper {
|
||||
s := &ArchiveSweeper{
|
||||
params SweeperParams,
|
||||
) *Sweeper {
|
||||
s := &Sweeper{
|
||||
db: params.Database,
|
||||
eng: params.Engine,
|
||||
writers: params.Writers,
|
||||
log: params.Logger.Get(),
|
||||
interval: params.Config.RetentionSweepInterval,
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func NewArchiveSweeper(
|
||||
// lifecycle. The start hook's context is deliberately ignored
|
||||
// (see start for why the background loop must not inherit it);
|
||||
// the stop hook's context is honoured (see stop).
|
||||
func (s *ArchiveSweeper) registerHooks(lc fx.Lifecycle) {
|
||||
func (s *Sweeper) registerHooks(lc fx.Lifecycle) {
|
||||
lc.Append(fx.Hook{
|
||||
//nolint:contextcheck // Not passing the hook context is
|
||||
// the point: see start.
|
||||
@@ -103,7 +103,7 @@ func (s *ArchiveSweeper) registerHooks(lc fx.Lifecycle) {
|
||||
// goroutine must outlive the startup phase, so its lifetime is
|
||||
// bounded by OnStop instead: stop cancels this context and waits
|
||||
// on the WaitGroup.
|
||||
func (s *ArchiveSweeper) start() {
|
||||
func (s *Sweeper) start() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *ArchiveSweeper) start() {
|
||||
// exit, bounded by the stop hook's context: a prune wedged on a
|
||||
// locked archive must not hang the process past fx's stop
|
||||
// timeout.
|
||||
func (s *ArchiveSweeper) stop(ctx context.Context) error {
|
||||
func (s *Sweeper) stop(ctx context.Context) error {
|
||||
s.log.Info("archive sweeper stopping")
|
||||
|
||||
if s.cancel != nil {
|
||||
@@ -140,7 +140,7 @@ func (s *ArchiveSweeper) stop(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ArchiveSweeper) run(ctx context.Context) {
|
||||
func (s *Sweeper) run(ctx context.Context) {
|
||||
defer s.wg.Done()
|
||||
|
||||
ticker := time.NewTicker(s.interval)
|
||||
@@ -164,7 +164,7 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
|
||||
// A failure for one target is logged and the sweep continues,
|
||||
// matching how the write path already treats a prune error as
|
||||
// non-fatal.
|
||||
func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
||||
func (s *Sweeper) sweep(ctx context.Context) {
|
||||
var targets []database.Target
|
||||
|
||||
err := s.db.DB().
|
||||
@@ -199,32 +199,20 @@ func (s *ArchiveSweeper) sweep(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// sweepTarget prunes the archive of a single database target.
|
||||
// A missing, empty, or "never" expiry parses as a zero duration
|
||||
// and is skipped entirely, so those archives keep exactly the
|
||||
// behaviour they had before the sweep existed.
|
||||
func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
|
||||
expiry, err := parseArchiveExpiry(target.Config)
|
||||
if err != nil {
|
||||
s.log.Error(
|
||||
"archive sweep: invalid database target config",
|
||||
"webhook_id", target.WebhookID,
|
||||
"target_id", target.ID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// sweepTarget prunes the archive of a single database target. A
|
||||
// target with an ArchiveExpiry of 0 keeps its archive forever and is
|
||||
// skipped entirely, so that archive is never pruned.
|
||||
func (s *Sweeper) sweepTarget(target *database.Target) {
|
||||
expiry := target.ArchiveExpiry
|
||||
if expiry <= 0 {
|
||||
return
|
||||
}
|
||||
|
||||
if s.eng == nil || s.eng.dbTarget == nil {
|
||||
if s.writers == nil {
|
||||
return
|
||||
}
|
||||
|
||||
err = s.eng.dbTarget.sweepArchive(target.ID, expiry)
|
||||
err := s.writers.sweepArchive(target.ID, expiry)
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package delivery_test
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -16,12 +16,15 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
@@ -35,6 +38,18 @@ const (
|
||||
// sweepConcurrentWrites is how many deliveries the
|
||||
// concurrent write-plus-sweep test races against the sweep.
|
||||
sweepConcurrentWrites = 20
|
||||
|
||||
// hookStopTimeout bounds how long a lifecycle test waits for
|
||||
// the sweeper's OnStop hook to return before declaring the
|
||||
// shutdown hung.
|
||||
hookStopTimeout = 10 * time.Second
|
||||
|
||||
// wedgeStopTimeout is the stop timeout a wedged-shutdown test
|
||||
// hands OnStop, standing in for fx's StopTimeout. The test
|
||||
// asserts only that the hook returns at all, and allows it
|
||||
// hookStopTimeout — forty times this budget — to do so, so no
|
||||
// assertion here races the wall clock.
|
||||
wedgeStopTimeout = 250 * time.Millisecond
|
||||
)
|
||||
|
||||
// archiveTestWebhookName is the name of every webhook
|
||||
@@ -43,12 +58,12 @@ const (
|
||||
const archiveTestWebhookName = "Sweep Test!"
|
||||
|
||||
// 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.
|
||||
// configuration database holding webhooks and targets, the archive
|
||||
// writers, the archive sweeper, and the data directory the archive
|
||||
// files live in.
|
||||
type archiveEnv struct {
|
||||
sweeper *delivery.ArchiveSweeper
|
||||
eng *delivery.Engine
|
||||
sweeper *archive.Sweeper
|
||||
writers *archive.Writers
|
||||
mainDB *database.Database
|
||||
dataDir string
|
||||
}
|
||||
@@ -59,59 +74,42 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||
dataDir := t.TempDir()
|
||||
log := archiveTestLogger()
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite",
|
||||
fmt.Sprintf(
|
||||
"file:%s?mode=rwc",
|
||||
filepath.Join(dataDir, "main.db"),
|
||||
),
|
||||
)
|
||||
mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
t.Cleanup(func() { _ = mainDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
mainDB := database.NewTestDatabase(gdb)
|
||||
require.NoError(t, mainDB.Migrate())
|
||||
|
||||
eng := delivery.NewTestEngineWithDB(
|
||||
mainDB,
|
||||
database.NewTestWebhookDBManager(dataDir),
|
||||
log,
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
)
|
||||
writers := archive.NewWriters(archive.WritersParams{
|
||||
Database: mainDB,
|
||||
DBManager: databasetest.NewWebhookDBManager(t, dataDir),
|
||||
Logger: log,
|
||||
})
|
||||
|
||||
return &archiveEnv{
|
||||
sweeper: delivery.NewTestArchiveSweeper(
|
||||
mainDB, eng, log,
|
||||
sweeper: archive.NewTestSweeper(
|
||||
mainDB, writers, log,
|
||||
),
|
||||
eng: eng,
|
||||
writers: writers,
|
||||
mainDB: mainDB,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
}
|
||||
|
||||
// archivePath returns where the engine keeps a database target's
|
||||
// archivePath returns where the writers keep a database target's
|
||||
// archive file, for the names seedDatabaseTarget gave it.
|
||||
func (env *archiveEnv) archivePath(tgt *database.Target) string {
|
||||
return filepath.Join(
|
||||
env.dataDir,
|
||||
delivery.ArchiveFileName(
|
||||
archive.FileName(
|
||||
archiveTestWebhookName, tgt.Name, tgt.ID,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// seedDatabaseTarget creates a webhook with one database target
|
||||
// carrying the given target config JSON, and returns the target.
|
||||
// seedDatabaseTarget creates a webhook with one database target with
|
||||
// the given archive expiry and rotation, and returns the target.
|
||||
func (env *archiveEnv) seedDatabaseTarget(
|
||||
t *testing.T, configJSON string,
|
||||
t *testing.T, expiry time.Duration, rotation string,
|
||||
) *database.Target {
|
||||
t.Helper()
|
||||
|
||||
@@ -126,22 +124,27 @@ func (env *archiveEnv) seedDatabaseTarget(
|
||||
Create(wh).Error,
|
||||
)
|
||||
|
||||
return env.addDatabaseTarget(t, wh.ID, configJSON)
|
||||
return env.addDatabaseTarget(t, wh.ID, expiry, rotation)
|
||||
}
|
||||
|
||||
// addDatabaseTarget creates one more database target on an
|
||||
// existing webhook and returns it.
|
||||
func (env *archiveEnv) addDatabaseTarget(
|
||||
t *testing.T, webhookID, configJSON string,
|
||||
t *testing.T,
|
||||
webhookID string,
|
||||
expiry time.Duration,
|
||||
rotation string,
|
||||
) *database.Target {
|
||||
t.Helper()
|
||||
|
||||
tgt := &database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: "Archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Active: true,
|
||||
Config: configJSON,
|
||||
WebhookID: webhookID,
|
||||
Name: "Archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Active: true,
|
||||
ArchiveExpiry: expiry,
|
||||
ArchiveRotation: rotation,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
@@ -186,12 +189,10 @@ func seedArchiveFile(
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(
|
||||
t, gdb.AutoMigrate(&delivery.ExportArchivedEvent{}),
|
||||
)
|
||||
require.NoError(t, migrations.Apply(gdb, migrations.Archive))
|
||||
|
||||
for i, at := range archivedAt {
|
||||
row := delivery.ExportArchivedEvent{
|
||||
row := archive.ExportArchivedEvent{
|
||||
EventID: fmt.Sprintf("ev-%d", i),
|
||||
WebhookID: webhookID,
|
||||
Method: http.MethodPost,
|
||||
@@ -211,7 +212,7 @@ func archivedEventIDs(
|
||||
) []string {
|
||||
t.Helper()
|
||||
|
||||
var rows []delivery.ExportArchivedEvent
|
||||
var rows []archive.ExportArchivedEvent
|
||||
|
||||
rdb := openArchiveDBForRead(t, path)
|
||||
require.NoError(t, rdb.Order("event_id").Find(&rows).Error)
|
||||
@@ -248,7 +249,7 @@ func countArchivedRows(path string) (int64, error) {
|
||||
|
||||
var count int64
|
||||
|
||||
err = gdb.Model(&delivery.ExportArchivedEvent{}).
|
||||
err = gdb.Model(&archive.ExportArchivedEvent{}).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
@@ -257,6 +258,55 @@ func countArchivedRows(path string) (int64, error) {
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// recordingLifecycle is a minimal fx.Lifecycle that records the
|
||||
// hooks a component registers, so a test can invoke the real
|
||||
// OnStart/OnStop functions with a context of its choosing.
|
||||
type recordingLifecycle struct {
|
||||
hooks []fx.Hook
|
||||
}
|
||||
|
||||
func (l *recordingLifecycle) Append(h fx.Hook) {
|
||||
l.hooks = append(l.hooks, h)
|
||||
}
|
||||
|
||||
// requireStopHookExpires drives hook.OnStop with a stop context
|
||||
// that expires while a wedged goroutine is still running, and
|
||||
// requires the hook to return the deadline error naming
|
||||
// component instead of blocking on the WaitGroup forever.
|
||||
func requireStopHookExpires(
|
||||
t *testing.T, hook fx.Hook, component string,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
stopCtx, cancel := context.WithTimeout(
|
||||
context.Background(), wedgeStopTimeout,
|
||||
)
|
||||
defer cancel()
|
||||
|
||||
var stopErr error
|
||||
|
||||
stopped := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
|
||||
stopErr = hook.OnStop(stopCtx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-stopped:
|
||||
case <-time.After(hookStopTimeout):
|
||||
t.Fatal(
|
||||
"OnStop did not return: it discarded the stop " +
|
||||
"context and is waiting on a wedged goroutine " +
|
||||
"that will never observe cancellation",
|
||||
)
|
||||
}
|
||||
|
||||
require.ErrorIs(t, stopErr, context.DeadlineExceeded)
|
||||
require.ErrorContains(t, stopErr, component)
|
||||
}
|
||||
|
||||
// TestArchiveSweeper_LoopOutlivesStartHookContext is the
|
||||
// regression test for a sweeper that never swept. fx calls
|
||||
// OnStart with a context carrying the application's start
|
||||
@@ -277,7 +327,7 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
|
||||
now := time.Now()
|
||||
path := env.seedArchiveRows(
|
||||
@@ -331,7 +381,7 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
@@ -340,16 +390,16 @@ func TestArchiveSweep_DoesNotResurrectEvictedWriter(
|
||||
// the deletion path does. The target row is deliberately left
|
||||
// in place: this is the tick that listed the target before
|
||||
// the deletion committed.
|
||||
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
||||
_, err := env.writers.ExportEnsureArchiveWriter(tgt.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
env.eng.EvictTarget(tgt.ID)
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
env.writers.EvictTarget(tgt.ID)
|
||||
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID))
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"a sweep must never re-register a writer for a target "+
|
||||
"whose registry entry has already been released",
|
||||
)
|
||||
@@ -365,14 +415,14 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
time.Now().Add(-48*time.Hour),
|
||||
time.Now().Add(-time.Minute),
|
||||
)
|
||||
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID))
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
@@ -381,7 +431,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
|
||||
"the sweep must still prune an idle archive",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"the sweep must release the registry entry it created",
|
||||
)
|
||||
}
|
||||
@@ -398,29 +448,27 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID))
|
||||
require.False(t, env.writers.ExportHasArchiveWriter(tgt.ID))
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
require.NoError(t, env.writers.Write(d))
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"a delivery's writer must stay registered",
|
||||
)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
assert.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"a sweep must not drop a writer a delivery owns",
|
||||
)
|
||||
}
|
||||
@@ -454,12 +502,12 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
sweepWriter, created, err := env.eng.ExportSweepWriterFor(
|
||||
sweepWriter, created, err := env.writers.ExportSweepWriterFor(
|
||||
tgt.ID,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -469,36 +517,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
||||
)
|
||||
|
||||
// The delivery lands mid-sweep and adopts the entry.
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
require.NoError(t, env.writers.Write(d))
|
||||
|
||||
adopted := env.eng.ExportArchiveWriterFor(tgt.ID)
|
||||
adopted := env.writers.ExportArchiveWriterFor(tgt.ID)
|
||||
require.NotNil(t, adopted)
|
||||
require.True(
|
||||
t, sweepWriter.Same(adopted),
|
||||
"the delivery must have adopted the sweep's writer",
|
||||
)
|
||||
require.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
|
||||
"the delivery leaves the archive handle open",
|
||||
)
|
||||
|
||||
// The sweep finishes.
|
||||
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
|
||||
env.writers.ExportReleaseSweepWriter(tgt.ID, sweepWriter)
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"a writer adopted by a delivery during a sweep must "+
|
||||
"stay registered, or its open handle is unreachable",
|
||||
)
|
||||
|
||||
env.eng.EvictTarget(tgt.ID)
|
||||
env.writers.EvictTarget(tgt.ID)
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"the adopted writer must still be evictable",
|
||||
)
|
||||
assert.False(
|
||||
@@ -509,8 +555,8 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
|
||||
|
||||
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a
|
||||
// failure for one target does not abort the sweep for the
|
||||
// others: an unparseable expiry and an unreadable archive both
|
||||
// have to be logged and stepped over.
|
||||
// others: an unreadable archive has to be logged and stepped
|
||||
// over.
|
||||
func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -518,21 +564,16 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
// Seeded first so the sweep reaches them before the healthy
|
||||
// Seeded first so the sweep reaches it before the healthy
|
||||
// target: targets come back in insertion order.
|
||||
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
|
||||
env.seedArchiveRows(
|
||||
t, badConfig, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
corrupt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
require.NoError(t, os.WriteFile(
|
||||
env.archivePath(corrupt),
|
||||
[]byte("this is not a sqlite database"),
|
||||
0o600,
|
||||
))
|
||||
|
||||
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
healthy := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
healthyPath := env.seedArchiveRows(
|
||||
t, healthy,
|
||||
time.Now().Add(-48*time.Hour),
|
||||
@@ -562,7 +603,7 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "archive-absent.db")
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
@@ -589,7 +630,7 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
|
||||
now := time.Now()
|
||||
path := env.seedArchiveRows(
|
||||
@@ -628,12 +669,12 @@ func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
@@ -668,30 +709,28 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
d := databaseTargetDelivery(newEvent(`{"n":1}`), tgt)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
require.NoError(t, env.writers.Write(d))
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
|
||||
"the delivery must leave the archive handle open",
|
||||
)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
require.True(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"the delivery's registry entry must survive the sweep",
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
|
||||
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
|
||||
"the sweep must leave the archive closed",
|
||||
)
|
||||
}
|
||||
@@ -707,7 +746,7 @@ func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive.db")
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
w := archive.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
@@ -735,8 +774,8 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
|
||||
|
||||
var errorLines bytes.Buffer
|
||||
|
||||
sweeper := delivery.NewTestArchiveSweeper(
|
||||
env.mainDB, env.eng,
|
||||
sweeper := archive.NewTestSweeper(
|
||||
env.mainDB, env.writers,
|
||||
slog.New(slog.NewTextHandler(
|
||||
&errorLines,
|
||||
&slog.HandlerOptions{Level: slog.LevelError},
|
||||
@@ -755,36 +794,29 @@ func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
|
||||
// no-op for the default retention policy, so archives with no
|
||||
// expiry (or the literal "never") behave exactly as before.
|
||||
// no-op for the default retention policy, so an archive with an
|
||||
// expiry of 0, which keeps it forever, is left alone.
|
||||
func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, configJSON := range []string{
|
||||
`{"expiry":"never"}`,
|
||||
`{"expiry":""}`,
|
||||
"",
|
||||
} {
|
||||
env := setupArchiveTest(t)
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, configJSON)
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
time.Now().Add(-10000*time.Hour),
|
||||
)
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt,
|
||||
time.Now().Add(-10000*time.Hour),
|
||||
)
|
||||
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
env.sweeper.ExportSweep(context.Background())
|
||||
|
||||
assert.Equal(
|
||||
t, []string{sweepRowOld}, archivedEventIDs(t, path),
|
||||
"config %q must keep rows forever", configJSON,
|
||||
)
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
"config %q must leave no registry entry behind",
|
||||
configJSON,
|
||||
)
|
||||
}
|
||||
assert.Equal(
|
||||
t, []string{sweepRowOld}, archivedEventIDs(t, path),
|
||||
"an expiry of 0 must keep rows forever",
|
||||
)
|
||||
assert.False(
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"an expiry of 0 must leave no registry entry behind",
|
||||
)
|
||||
}
|
||||
|
||||
// TestArchiveSweep_NeverExpirySkipsBeforeOpening pins the
|
||||
@@ -795,9 +827,9 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
|
||||
//
|
||||
// The spec is stronger than that — a "never" archive is skipped
|
||||
// before any file is touched — so the archive here exists but has
|
||||
// never been migrated. Opening it at all would run AutoMigrate
|
||||
// and create the archive table, which is exactly what must not
|
||||
// happen.
|
||||
// never been migrated. Opening it at all would apply the archive
|
||||
// schema and create the archive table, which is exactly what must
|
||||
// not happen.
|
||||
func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -805,7 +837,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
|
||||
tgt := env.seedDatabaseTarget(t, 0, "")
|
||||
path := env.archivePath(tgt)
|
||||
|
||||
seedUnmigratedArchive(t, path)
|
||||
@@ -821,7 +853,7 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
|
||||
|
||||
// seedUnmigratedArchive creates an archive file that exists but
|
||||
// carries no archive schema, so any open of it is observable: the
|
||||
// archive table appears only if something ran AutoMigrate.
|
||||
// archive table appears only if something applied the schema.
|
||||
func seedUnmigratedArchive(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -845,7 +877,7 @@ func archiveTableExists(t *testing.T, path string) bool {
|
||||
|
||||
return openArchiveDBForRead(t, path).
|
||||
Migrator().
|
||||
HasTable(&delivery.ExportArchivedEvent{})
|
||||
HasTable(&archive.ExportArchivedEvent{})
|
||||
}
|
||||
|
||||
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
|
||||
@@ -857,7 +889,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
path := env.archivePath(tgt)
|
||||
|
||||
require.NoFileExists(t, path)
|
||||
@@ -872,7 +904,7 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.False(
|
||||
t, env.eng.ExportHasArchiveWriter(tgt.ID),
|
||||
t, env.writers.ExportHasArchiveWriter(tgt.ID),
|
||||
"the sweep must leave no registry entry behind",
|
||||
)
|
||||
}
|
||||
@@ -888,9 +920,9 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
|
||||
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID)
|
||||
path, err := env.writers.ExportEnsureArchiveWriter(tgt.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoFileExists(t, path)
|
||||
|
||||
@@ -907,7 +939,7 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
path := env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
@@ -935,23 +967,16 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
|
||||
// The deliveries are seeded up front, on the test's own
|
||||
// goroutine: the seed helpers assert, and testify assertions
|
||||
// must not run off the test goroutine.
|
||||
deliveries := make(
|
||||
[]*database.Delivery, 0, sweepConcurrentWrites,
|
||||
)
|
||||
|
||||
for range sweepConcurrentWrites {
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
|
||||
deliveries = append(
|
||||
deliveries,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
databaseTargetDelivery(newEvent(`{"n":1}`), tgt),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -963,7 +988,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
|
||||
defer wg.Done()
|
||||
|
||||
for _, d := range deliveries {
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
_ = env.writers.Write(d)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -987,7 +1012,7 @@ func TestArchiveSweeper_StopsCleanly(t *testing.T) {
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
|
||||
tgt := env.seedDatabaseTarget(t, time.Hour, "")
|
||||
env.seedArchiveRows(
|
||||
t, tgt, time.Now().Add(-48*time.Hour),
|
||||
)
|
||||
@@ -1,7 +1,6 @@
|
||||
package delivery
|
||||
package archive
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
@@ -14,12 +13,14 @@ import (
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
// archiveExpiryNever is the expiry sentinel (and default) that
|
||||
// disables pruning so archived rows are kept forever.
|
||||
const archiveExpiryNever = "never"
|
||||
// ExpiryNever is the archive expiry, as the target forms take it,
|
||||
// that disables pruning so archived rows are kept forever. It is
|
||||
// stored as an ArchiveExpiry of 0.
|
||||
const ExpiryNever = "never"
|
||||
|
||||
// archiveReopenDebounce bounds how often an archive file is
|
||||
// closed and reopened. After each write the handle is closed
|
||||
@@ -70,27 +71,14 @@ var (
|
||||
"archive writer has been evicted",
|
||||
)
|
||||
|
||||
// ErrArchiveNameTaken is returned when an archive cannot be
|
||||
// ErrNameTaken is returned when an archive cannot be
|
||||
// renamed because a file already has the new name. That file may
|
||||
// be an archive with rows of its own, so it is never replaced.
|
||||
ErrArchiveNameTaken = errors.New(
|
||||
ErrNameTaken = errors.New(
|
||||
"a file already has the archive's new name",
|
||||
)
|
||||
)
|
||||
|
||||
// databaseTargetConfig is the optional per-target JSON config
|
||||
// for a database (archive) target.
|
||||
type databaseTargetConfig struct {
|
||||
// Expiry is a Go duration (e.g. "720h") after which
|
||||
// archived rows are pruned, or "never" (the default) to
|
||||
// keep them forever.
|
||||
Expiry string `json:"expiry"`
|
||||
|
||||
// Rotation is none (the default), monthly, daily or hourly: see
|
||||
// archivePeriod.
|
||||
Rotation string `json:"rotation"`
|
||||
}
|
||||
|
||||
// archivedEvent is one fully captured webhook event stored in a
|
||||
// database target's archive for long-term retention. It is a
|
||||
// self-contained copy — independent of the per-webhook event
|
||||
@@ -101,6 +89,7 @@ type archivedEvent struct {
|
||||
WebhookID string
|
||||
EntrypointID string
|
||||
Method string
|
||||
RawQuery string
|
||||
Headers string
|
||||
Body string
|
||||
ContentType string
|
||||
@@ -110,76 +99,33 @@ type archivedEvent struct {
|
||||
ArchivedAt time.Time `gorm:"index"`
|
||||
}
|
||||
|
||||
// parseArchiveExpiry reads the optional expiry from a database
|
||||
// target's config JSON. An empty config, an empty expiry, or
|
||||
// the literal "never" all mean keep forever, returned as a zero
|
||||
// duration. Any other value must parse as a positive Go
|
||||
// duration; a set-but-invalid value (unparseable, zero, or
|
||||
// negative) is an error rather than a silent default, matching
|
||||
// ValidateArchiveExpiry at target creation.
|
||||
func parseArchiveExpiry(
|
||||
configJSON string,
|
||||
) (time.Duration, error) {
|
||||
if configJSON == "" {
|
||||
// ParseExpiry reads a user-supplied archive expiry for a database
|
||||
// target, as the target forms submit it, into the ArchiveExpiry the
|
||||
// target stores. Empty and "never" both mean keep forever, which is
|
||||
// 0; anything else must be a positive Go duration such as "720h". Any
|
||||
// other value is an error, so a bad expiry is refused at the form
|
||||
// rather than stored.
|
||||
func ParseExpiry(expiry string) (time.Duration, error) {
|
||||
if expiry == "" || expiry == ExpiryNever {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
var cfg databaseTargetConfig
|
||||
|
||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"parsing database target config: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
dur, err := time.ParseDuration(cfg.Expiry)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf(
|
||||
"parsing archive expiry %q: %w", cfg.Expiry, err,
|
||||
)
|
||||
}
|
||||
|
||||
if dur <= 0 {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %q", errArchiveExpiryNotPositive, cfg.Expiry,
|
||||
)
|
||||
}
|
||||
|
||||
return dur, nil
|
||||
}
|
||||
|
||||
// ValidateArchiveExpiry checks a user-supplied archive expiry
|
||||
// for a database target at configuration time. Valid values are
|
||||
// empty, "never" (both meaning keep forever), or a positive Go
|
||||
// duration such as "720h". Anything else is an error, so a bad
|
||||
// expiry is rejected when the target is created rather than
|
||||
// failing every subsequent delivery.
|
||||
func ValidateArchiveExpiry(expiry string) error {
|
||||
if expiry == "" || expiry == archiveExpiryNever {
|
||||
return nil
|
||||
}
|
||||
|
||||
dur, err := time.ParseDuration(expiry)
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
return 0, fmt.Errorf(
|
||||
"expiry must be %q or a Go duration "+
|
||||
"such as \"720h\": %w",
|
||||
archiveExpiryNever, err,
|
||||
ExpiryNever, err,
|
||||
)
|
||||
}
|
||||
|
||||
if dur <= 0 {
|
||||
return fmt.Errorf(
|
||||
return 0, fmt.Errorf(
|
||||
"%w: %q", errArchiveExpiryNotPositive, expiry,
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
return dur, nil
|
||||
}
|
||||
|
||||
// archiveWriter owns one database target's archive SQLite files.
|
||||
@@ -192,7 +138,7 @@ func ValidateArchiveExpiry(expiry string) error {
|
||||
type archiveWriter struct {
|
||||
mu sync.Mutex
|
||||
|
||||
// path is the target's archive file as ArchivePath names it. A
|
||||
// path is the target's archive file as Path names it. A
|
||||
// target that rotates writes to the files archivePeriodPath names
|
||||
// for path and a period instead.
|
||||
path string
|
||||
@@ -230,7 +176,7 @@ type archiveWriter struct {
|
||||
// registry proper.
|
||||
//
|
||||
// Unlike every other field here it is guarded by
|
||||
// databaseTarget.mu, not by this writer's mu: it describes the
|
||||
// Writers.mu, not by this writer's mu: it describes the
|
||||
// registry entry rather than the file.
|
||||
sweepOwned bool
|
||||
}
|
||||
@@ -275,7 +221,11 @@ func (w *archiveWriter) write(
|
||||
}
|
||||
}
|
||||
|
||||
row.ArchivedAt = time.Now()
|
||||
// GORM's clock, the one that stamps created_at in the other
|
||||
// databases, so this is stored in the same form. A bare time.Now()
|
||||
// carries a monotonic clock reading, which the driver would store
|
||||
// as an " m=+…" suffix.
|
||||
row.ArchivedAt = w.db.NowFunc()
|
||||
|
||||
err := w.db.Create(&row).Error
|
||||
if err != nil {
|
||||
@@ -334,7 +284,7 @@ func (w *archiveWriter) openMode(
|
||||
)
|
||||
}
|
||||
|
||||
err = gdb.AutoMigrate(&archivedEvent{})
|
||||
err = migrations.Apply(gdb, migrations.Archive)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
@@ -493,7 +443,7 @@ func (w *archiveWriter) sweepFile(
|
||||
// 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
|
||||
// the error is ErrNameTaken. If one file fails to move, those
|
||||
// already moved are moved back before the error is returned, so the
|
||||
// archive is never split across two names.
|
||||
func (w *archiveWriter) rename(name string) error {
|
||||
@@ -531,7 +481,7 @@ func (w *archiveWriter) rename(name string) error {
|
||||
for _, taken := range to {
|
||||
if fileExists(taken) {
|
||||
return fmt.Errorf(
|
||||
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
|
||||
"%w: %s", ErrNameTaken, filepath.Base(taken),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -584,7 +534,7 @@ func (w *archiveWriter) evict() {
|
||||
// each row's archived time. It runs on every (re)open, so a
|
||||
// steadily written archive is swept by its own write traffic. An
|
||||
// archive that goes idle receives no further reopens, which is
|
||||
// why ArchiveSweeper exists to drive sweepExpired on a timer.
|
||||
// why Sweeper exists to drive sweepExpired on a timer.
|
||||
// Failures are logged, not fatal: a prune error must not stop
|
||||
// archiving.
|
||||
func (w *archiveWriter) prune(expiry time.Duration) {
|
||||
@@ -611,9 +561,9 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
||||
}
|
||||
}
|
||||
|
||||
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||
// FileInfo is what the metadata of a database target's archive
|
||||
// files says about them.
|
||||
type ArchiveFileInfo struct {
|
||||
type FileInfo struct {
|
||||
// Files counts the files.
|
||||
Files int
|
||||
|
||||
@@ -625,18 +575,18 @@ type ArchiveFileInfo struct {
|
||||
Written time.Time
|
||||
}
|
||||
|
||||
// StatArchive reads the metadata of a database target's archive
|
||||
// files, given the path ArchivePath gives it (see archiveFiles), and
|
||||
// Stat reads the metadata of a database target's archive
|
||||
// files, given the path Path gives it (see archiveFiles), and
|
||||
// of their -wal, without opening them. With no files, which is so
|
||||
// before the first write and after the operator moved them away, the
|
||||
// error wraps fs.ErrNotExist.
|
||||
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||
func Stat(path string) (FileInfo, error) {
|
||||
files, err := archiveFiles(path)
|
||||
if err != nil {
|
||||
return ArchiveFileInfo{}, err
|
||||
return FileInfo{}, err
|
||||
}
|
||||
|
||||
var info ArchiveFileInfo
|
||||
var info FileInfo
|
||||
|
||||
for _, file := range files {
|
||||
db, err := os.Stat(file.path)
|
||||
@@ -645,7 +595,7 @@ func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ArchiveFileInfo{}, err
|
||||
return FileInfo{}, err
|
||||
}
|
||||
|
||||
info.Files++
|
||||
@@ -661,7 +611,7 @@ func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return ArchiveFileInfo{}, err
|
||||
return FileInfo{}, err
|
||||
}
|
||||
|
||||
info.Size += wal.Size()
|
||||
@@ -672,7 +622,7 @@ func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||
}
|
||||
|
||||
if info.Files == 0 {
|
||||
return ArchiveFileInfo{}, fmt.Errorf(
|
||||
return FileInfo{}, fmt.Errorf(
|
||||
"no archive file for %s: %w", path, fs.ErrNotExist,
|
||||
)
|
||||
}
|
||||
+6
-6
@@ -1,4 +1,4 @@
|
||||
package delivery_test
|
||||
package archive_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
gormlogger "gorm.io/gorm/logger"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/middleware"
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ func (g *gormDefaultBuf) String() string {
|
||||
// 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,
|
||||
// internal/archive/writer.go — the archive writer,
|
||||
// whose type is unexported, so nothing outside this package can drive
|
||||
// it.
|
||||
func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
|
||||
@@ -85,7 +85,7 @@ func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
|
||||
// writer's gorm.Open to the adapter.
|
||||
//
|
||||
// Restore a bare &gorm.Config{} at
|
||||
// internal/delivery/target_database_archive.go and this fails: the
|
||||
// internal/archive/writer.go and this fails: the
|
||||
// default logger prints the fully interpolated SELECT on every
|
||||
// ErrRecordNotFound, so the client-chosen event id below arrives whole
|
||||
// and unbounded on stdout, answering to no level the operator set.
|
||||
@@ -100,7 +100,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
|
||||
|
||||
gormDefault := captureArchiveGORMDefault(t)
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
w := archive.NewExportArchiveWriter(
|
||||
filepath.Join(t.TempDir(), "archive.db"),
|
||||
slog.New(slog.NewTextHandler(
|
||||
&captured, &slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
@@ -118,7 +118,7 @@ func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
|
||||
value := strings.Repeat("\x01", archiveGORMFillBytes) +
|
||||
archiveGORMTailMarker
|
||||
|
||||
var row delivery.ExportArchivedEvent
|
||||
var row archive.ExportArchivedEvent
|
||||
|
||||
err := w.DB().Where("event_id = ?", value).First(&row).Error
|
||||
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
@@ -1,387 +0,0 @@
|
||||
package ciscript_test
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
// supersededDesc is the description script/ci-mark-superseded
|
||||
// writes, and the one an earlier revision of it wrote alongside a
|
||||
// `skipped` state.
|
||||
supersededDesc = "Superseded by a newer commit; never tested"
|
||||
|
||||
// liveContext is the commit-status context Gitea uses for this
|
||||
// repository's runs, as seen in its API. The script derives it from
|
||||
// the workflow and job names rather than hardcoding it; the
|
||||
// derivation is checked against this value below.
|
||||
liveContext = "check / check (push)"
|
||||
|
||||
scriptPath = "../../script/ci-mark-superseded"
|
||||
workflow = "../../.gitea/workflows/check.yml"
|
||||
|
||||
// failure is the only state that neither folds into a combined
|
||||
// `success` (as `skipped` does) nor blocks the commit forever (as
|
||||
// `pending` does).
|
||||
failure = "failure"
|
||||
)
|
||||
|
||||
// repo is a throwaway git history: parent is the commit a run would be
|
||||
// cancelled on, head the commit that superseded it.
|
||||
type repo struct {
|
||||
dir string
|
||||
head string
|
||||
parent string
|
||||
}
|
||||
|
||||
// scriptEnv is the run identity the Gitea runner exports and the script
|
||||
// builds its context string from.
|
||||
type scriptEnv struct {
|
||||
workflow string
|
||||
job string
|
||||
event string
|
||||
}
|
||||
|
||||
func defaultEnv() scriptEnv {
|
||||
return scriptEnv{workflow: "check", job: "check", event: "push"}
|
||||
}
|
||||
|
||||
func cancelled() commitStatus {
|
||||
return commitStatus{
|
||||
Context: liveContext,
|
||||
Status: failure,
|
||||
Description: "Has been cancelled",
|
||||
}
|
||||
}
|
||||
|
||||
func running() commitStatus {
|
||||
return commitStatus{
|
||||
Context: liveContext,
|
||||
Status: "pending",
|
||||
Description: "Has started running",
|
||||
}
|
||||
}
|
||||
|
||||
func TestMarkSuperseded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]struct {
|
||||
parent commitStatus
|
||||
wantMark bool
|
||||
}{
|
||||
"a cancelled run is marked": {
|
||||
parent: cancelled(),
|
||||
wantMark: true,
|
||||
},
|
||||
"a laundered skipped status is marked": {
|
||||
parent: commitStatus{
|
||||
Context: liveContext,
|
||||
Status: "skipped",
|
||||
Description: supersededDesc,
|
||||
},
|
||||
wantMark: true,
|
||||
},
|
||||
"a genuine failure is left alone": {
|
||||
parent: commitStatus{
|
||||
Context: liveContext,
|
||||
Status: failure,
|
||||
Description: "Failing after 3m1s",
|
||||
},
|
||||
wantMark: false,
|
||||
},
|
||||
"a passing run is left alone": {
|
||||
parent: commitStatus{
|
||||
Context: liveContext,
|
||||
Status: "success",
|
||||
Description: "Successful in 2m52s",
|
||||
},
|
||||
wantMark: false,
|
||||
},
|
||||
"another context is left alone": {
|
||||
parent: commitStatus{
|
||||
Context: "other / other (push)",
|
||||
Status: failure,
|
||||
Description: "Has been cancelled",
|
||||
},
|
||||
wantMark: false,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
history := newRepo(t)
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, tc.parent)
|
||||
|
||||
out, err := runScript(t, history, api, defaultEnv())
|
||||
require.NoError(t, err, out)
|
||||
|
||||
posted := fake.postedFor(history.parent)
|
||||
if !tc.wantMark {
|
||||
require.Empty(t, posted)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.Equal(t, []postedStatus{{
|
||||
Context: liveContext,
|
||||
// Not `skipped`: Gitea's combined status folds
|
||||
// that into `success`, which is what made a
|
||||
// never-tested commit read green.
|
||||
State: failure,
|
||||
Description: supersededDesc,
|
||||
}}, posted)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A second run must not rewrite what the first one wrote, or every
|
||||
// later push would post a duplicate status.
|
||||
func TestMarkSupersededIsIdempotent(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
history := newRepo(t)
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, cancelled())
|
||||
|
||||
for range 2 {
|
||||
out, err := runScript(t, history, api, defaultEnv())
|
||||
require.NoError(t, err, out)
|
||||
}
|
||||
|
||||
require.Len(t, fake.postedFor(history.parent), 1)
|
||||
}
|
||||
|
||||
// Renaming the workflow or the job changes the context string Gitea
|
||||
// uses. The script must say so instead of quietly matching nothing.
|
||||
func TestMarkSupersededRejectsAnUnknownContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
history := newRepo(t)
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, cancelled())
|
||||
|
||||
env := defaultEnv()
|
||||
env.job = "renamed"
|
||||
|
||||
out, err := runScript(t, history, api, env)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, out, "renamed")
|
||||
require.Contains(t, out, liveContext)
|
||||
require.Empty(t, fake.postedFor(history.parent))
|
||||
}
|
||||
|
||||
// ANCESTOR_LIMIT is a documented knob. A value that is set but unusable
|
||||
// must abort: handing it to git and discarding the exit status left the
|
||||
// walk empty and the step green, marking nothing.
|
||||
func TestMarkSupersededRejectsAnUnparseableAncestorLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
history := newRepo(t)
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, cancelled())
|
||||
|
||||
out, err := runScript(
|
||||
t, history, api, defaultEnv(), "ANCESTOR_LIMIT=twenty",
|
||||
)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, out, "ANCESTOR_LIMIT")
|
||||
require.Contains(t, out, "twenty")
|
||||
require.Empty(t, fake.postedFor(history.parent))
|
||||
}
|
||||
|
||||
// A status read that fails is not the same as a commit with nothing to
|
||||
// do. Losing curl's exit status through a pipe made the two identical
|
||||
// and left a laundered commit laundered with no signal.
|
||||
func TestMarkSupersededFailsOnAnUnreadableAncestorStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
history := newRepo(t)
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, cancelled())
|
||||
fake.failStatusRead(history.parent)
|
||||
|
||||
out, err := runScript(t, history, api, defaultEnv())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, out, history.parent)
|
||||
require.Contains(t, out, "cannot read commit statuses")
|
||||
require.Empty(t, fake.postedFor(history.parent))
|
||||
}
|
||||
|
||||
// A shallow clone cannot resolve the parent, so it is indistinguishable
|
||||
// from a root commit to rev-parse and the walk would exit 0 having
|
||||
// marked nothing. It must abort instead: dropping `fetch-depth: 0` from
|
||||
// the checkout step is one edit, and a silent no-op there restores the
|
||||
// false-green bug this script exists to prevent.
|
||||
func TestMarkSupersededRejectsAShallowRepository(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
history := shallowClone(t, newRepo(t))
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, cancelled())
|
||||
|
||||
out, err := runScript(t, history, api, defaultEnv())
|
||||
require.Error(t, err)
|
||||
require.Contains(t, out, "shallow repository")
|
||||
require.Empty(t, fake.postedFor(history.parent))
|
||||
require.Empty(t, fake.postedFor(history.head))
|
||||
}
|
||||
|
||||
// shallowClone returns the same history as a depth-1 clone. The `file://`
|
||||
// URL is required: git ignores --depth for a plain local path.
|
||||
func shallowClone(t *testing.T, history repo) repo {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
//nolint:gosec // fixed argv, arguments are test-local paths
|
||||
cmd := exec.CommandContext(t.Context(), "git", "clone", "-q",
|
||||
"--depth=1", "file://"+history.dir, dir)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return repo{dir: dir, head: history.head, parent: history.parent}
|
||||
}
|
||||
|
||||
// The derived context must equal the one Gitea actually uses, which is
|
||||
// built from the same workflow and job names.
|
||||
func TestDerivedContextMatchesGitea(t *testing.T) {
|
||||
t.Parallel()
|
||||
requireTools(t)
|
||||
|
||||
name, job := workflowIdentity(t)
|
||||
|
||||
history := newRepo(t)
|
||||
fake, api := newFakeGitea(t)
|
||||
fake.setStatus(history.head, running())
|
||||
fake.setStatus(history.parent, cancelled())
|
||||
|
||||
out, err := runScript(t, history, api, scriptEnv{
|
||||
workflow: name,
|
||||
job: job,
|
||||
event: "push",
|
||||
})
|
||||
require.NoError(t, err, out)
|
||||
|
||||
posted := fake.postedFor(history.parent)
|
||||
require.Len(t, posted, 1)
|
||||
require.Equal(t, liveContext, posted[0].Context)
|
||||
}
|
||||
|
||||
// workflowIdentity reads the workflow name and its single job id out of
|
||||
// the checked-in workflow file.
|
||||
func workflowIdentity(t *testing.T) (string, string) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := os.ReadFile(workflow)
|
||||
require.NoError(t, err)
|
||||
|
||||
var parsed struct {
|
||||
Name string `yaml:"name"`
|
||||
Jobs map[string]any `yaml:"jobs"`
|
||||
}
|
||||
|
||||
require.NoError(t, yaml.Unmarshal(raw, &parsed))
|
||||
|
||||
jobs := slices.Collect(maps.Keys(parsed.Jobs))
|
||||
require.Len(t, jobs, 1)
|
||||
|
||||
return parsed.Name, jobs[0]
|
||||
}
|
||||
|
||||
func runScript(
|
||||
t *testing.T, history repo, api string, env scriptEnv,
|
||||
extra ...string,
|
||||
) (string, error) {
|
||||
t.Helper()
|
||||
|
||||
script, err := filepath.Abs(scriptPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:gosec // fixed argv, repo-local script under test
|
||||
cmd := exec.CommandContext(t.Context(), "sh", script)
|
||||
cmd.Dir = history.dir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GITHUB_API_URL="+api,
|
||||
"GITHUB_REPOSITORY=sneak/webhooker",
|
||||
"GITHUB_SHA="+history.head,
|
||||
"GITHUB_WORKFLOW="+env.workflow,
|
||||
"GITHUB_JOB="+env.job,
|
||||
"GITHUB_EVENT_NAME="+env.event,
|
||||
"GITEA_TOKEN=test-token",
|
||||
)
|
||||
cmd.Env = append(cmd.Env, extra...)
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func newRepo(t *testing.T) repo {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
|
||||
git := func(args ...string) string {
|
||||
//nolint:gosec // fixed argv, arguments are test constants
|
||||
cmd := exec.CommandContext(t.Context(), "git", args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, string(out))
|
||||
|
||||
return strings.TrimSpace(string(out))
|
||||
}
|
||||
|
||||
commit := func(message string) string {
|
||||
git(
|
||||
"-c", "user.email=ci@example.invalid",
|
||||
"-c", "user.name=ci",
|
||||
"-c", "commit.gpgsign=false",
|
||||
"commit", "-q", "--allow-empty", "-m", message,
|
||||
)
|
||||
|
||||
return git("rev-parse", "HEAD")
|
||||
}
|
||||
|
||||
git("init", "-q", "-b", "main")
|
||||
|
||||
parent := commit("parent")
|
||||
head := commit("head")
|
||||
|
||||
return repo{dir: dir, head: head, parent: parent}
|
||||
}
|
||||
|
||||
func requireTools(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
for _, tool := range []string{"sh", "git", "curl", "jq"} {
|
||||
_, err := exec.LookPath(tool)
|
||||
if err != nil {
|
||||
t.Skipf("%s is not installed: %v", tool, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
// Package ciscript holds the tests for the repository's CI shell
|
||||
// scripts in script/. It carries no runtime code: the scripts run on
|
||||
// the CI runner, not inside the binary, but their behaviour still has
|
||||
// to be verified by the test suite.
|
||||
//
|
||||
// The scripts under test are outside the Go build graph, so `go test`'s
|
||||
// result cache serves a stale PASS when only a script changed: run the
|
||||
// container build, or GOFLAGS=-count=1, to trust a result here after
|
||||
// editing script/.
|
||||
package ciscript
|
||||
@@ -1,162 +0,0 @@
|
||||
package ciscript_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// commitStatus is the part of an entry in Gitea's combined-status
|
||||
// response that script/ci-mark-superseded reads.
|
||||
type commitStatus struct {
|
||||
Context string `json:"context"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// postedStatus is the part of a create-status request body the script
|
||||
// writes.
|
||||
type postedStatus struct {
|
||||
Context string `json:"context"`
|
||||
State string `json:"state"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// fakeGitea serves the two endpoints the script talks to. Like Gitea,
|
||||
// the newest status for a context replaces the previous one, so a
|
||||
// second run of the script sees what the first one wrote.
|
||||
type fakeGitea struct {
|
||||
mu sync.Mutex
|
||||
statuses map[string][]commitStatus
|
||||
posted map[string][]postedStatus
|
||||
// failRead is a commit whose combined-status read answers HTTP
|
||||
// 500, standing in for a status API that is down.
|
||||
failRead string
|
||||
}
|
||||
|
||||
// newFakeGitea returns the fake and the base URL to hand the script as
|
||||
// GITHUB_API_URL.
|
||||
func newFakeGitea(t *testing.T) (*fakeGitea, string) {
|
||||
t.Helper()
|
||||
|
||||
fake := &fakeGitea{
|
||||
mu: sync.Mutex{},
|
||||
statuses: map[string][]commitStatus{},
|
||||
posted: map[string][]postedStatus{},
|
||||
failRead: "",
|
||||
}
|
||||
|
||||
srv := httptest.NewServer(fake.routes())
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return fake, srv.URL
|
||||
}
|
||||
|
||||
func (f *fakeGitea) routes() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
|
||||
mux.HandleFunc(
|
||||
"GET /repos/{owner}/{repo}/commits/{sha}/status",
|
||||
f.handleCombined,
|
||||
)
|
||||
mux.HandleFunc(
|
||||
"POST /repos/{owner}/{repo}/statuses/{sha}",
|
||||
f.handleCreate,
|
||||
)
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
func (f *fakeGitea) handleCombined(
|
||||
w http.ResponseWriter, r *http.Request,
|
||||
) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
sha := r.PathValue("sha")
|
||||
if f.failRead != "" && f.failRead == sha {
|
||||
http.Error(w, "boom", http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
body := struct {
|
||||
Statuses []commitStatus `json:"statuses"`
|
||||
}{Statuses: f.statuses[sha]}
|
||||
|
||||
payload, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
_, _ = w.Write(payload)
|
||||
}
|
||||
|
||||
func (f *fakeGitea) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var got postedStatus
|
||||
|
||||
err := json.NewDecoder(r.Body).Decode(&got)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
sha := r.PathValue("sha")
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.posted[sha] = append(f.posted[sha], got)
|
||||
f.replaceLocked(sha, commitStatus{
|
||||
Context: got.Context,
|
||||
Status: got.State,
|
||||
Description: got.Description,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
}
|
||||
|
||||
// failStatusRead makes the combined-status read for one commit answer
|
||||
// HTTP 500.
|
||||
func (f *fakeGitea) failStatusRead(sha string) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.failRead = sha
|
||||
}
|
||||
|
||||
// setStatus gives a commit its latest status for a context.
|
||||
func (f *fakeGitea) setStatus(sha string, status commitStatus) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
f.replaceLocked(sha, status)
|
||||
}
|
||||
|
||||
// postedFor returns the statuses the script created for a commit.
|
||||
func (f *fakeGitea) postedFor(sha string) []postedStatus {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
return append([]postedStatus(nil), f.posted[sha]...)
|
||||
}
|
||||
|
||||
// replaceLocked requires f.mu.
|
||||
func (f *fakeGitea) replaceLocked(sha string, status commitStatus) {
|
||||
for i, existing := range f.statuses[sha] {
|
||||
if existing.Context == status.Context {
|
||||
f.statuses[sha][i] = status
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
f.statuses[sha] = append(f.statuses[sha], status)
|
||||
}
|
||||
@@ -7,21 +7,22 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||
)
|
||||
|
||||
// TestClearEnvForTest_RemovesAddedVariables pins that a variable set
|
||||
// TestClearEnv_RemovesAddedVariables pins that a variable set
|
||||
// after the clear other than through t.Setenv, as a test's .env file
|
||||
// sets one, is gone once the test ends, so it cannot reach the tests
|
||||
// that run after it.
|
||||
//
|
||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
||||
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
|
||||
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||
func TestClearEnv_RemovesAddedVariables(t *testing.T) {
|
||||
// The outer clear keeps a value of the key exported in the shell
|
||||
// from making it a variable the inner clear has to put back.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
t.Run("loads a .env file after the clear", func(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
|
||||
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
||||
@@ -68,6 +68,10 @@ const (
|
||||
// authenticated activity before it expires.
|
||||
defaultSessionIdleTimeout = 24 * time.Hour
|
||||
|
||||
// defaultHSTSMaxAge is the max-age of the Strict-Transport-Security
|
||||
// header when HSTS_MAX_AGE is unset: ten years.
|
||||
defaultHSTSMaxAge = 87600 * time.Hour
|
||||
|
||||
// defaultReceiverRateLimit is the default number of requests
|
||||
// per minute each client IP may send to a single webhook
|
||||
// receiver entrypoint. Generous for legitimate webhook
|
||||
@@ -120,6 +124,10 @@ var ErrInvalidBindAddress = errors.New("invalid bind address")
|
||||
// the Sentry SDK cannot parse as a DSN.
|
||||
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
|
||||
|
||||
// ErrInvalidHSTSMaxAge is returned when HSTS_MAX_AGE is set to a
|
||||
// negative duration or one that is not a whole number of seconds.
|
||||
var ErrInvalidHSTSMaxAge = errors.New("invalid HSTS max-age")
|
||||
|
||||
// ErrDotEnvUnreadable is returned when the optional .env file exists
|
||||
// but cannot be read or parsed. A file that is not there is not an
|
||||
// error; a file that is there and broken is.
|
||||
@@ -169,6 +177,10 @@ type Config struct {
|
||||
// which a session expires. Non-positive disables idle expiry.
|
||||
SessionIdleTimeout time.Duration
|
||||
|
||||
// HSTSMaxAge is the max-age of the Strict-Transport-Security
|
||||
// header. Zero means the header is not sent at all.
|
||||
HSTSMaxAge time.Duration
|
||||
|
||||
// ReceiverRateLimit is the number of requests per minute each
|
||||
// client IP may send to a single webhook receiver entrypoint.
|
||||
ReceiverRateLimit int
|
||||
@@ -624,6 +636,27 @@ func resolveEnvironment() (string, error) {
|
||||
return environment, nil
|
||||
}
|
||||
|
||||
// resolveHSTSMaxAge reads HSTS_MAX_AGE. Zero is allowed and turns the
|
||||
// Strict-Transport-Security header off. A fraction of a second is an
|
||||
// error rather than rounded, because the header carries whole seconds
|
||||
// and would otherwise say something other than what was set.
|
||||
func resolveHSTSMaxAge() (time.Duration, error) {
|
||||
maxAge, err := envDuration("HSTS_MAX_AGE", defaultHSTSMaxAge)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if maxAge < 0 || maxAge%time.Second != 0 {
|
||||
return 0, fmt.Errorf(
|
||||
"%w: HSTS_MAX_AGE must be zero or a positive whole "+
|
||||
"number of seconds, got %s",
|
||||
ErrInvalidHSTSMaxAge, maxAge,
|
||||
)
|
||||
}
|
||||
|
||||
return maxAge, nil
|
||||
}
|
||||
|
||||
// resolveListener reads the two variables that describe the HTTP
|
||||
// listener: which port it claims and which address it claims it on.
|
||||
// They are read together because neither is meaningful alone, and
|
||||
@@ -684,6 +717,11 @@ func loadFromEnv() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hstsMaxAge, err := resolveHSTSMaxAge()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
receiverRateLimit, err := envPositiveInt(
|
||||
"RECEIVER_RATE_LIMIT",
|
||||
defaultReceiverRateLimit,
|
||||
@@ -723,6 +761,7 @@ func loadFromEnv() (*Config, error) {
|
||||
SentryDSN: sentryDSN,
|
||||
RetentionSweepInterval: retentionSweepInterval,
|
||||
SessionIdleTimeout: sessionIdleTimeout,
|
||||
HSTSMaxAge: hstsMaxAge,
|
||||
ReceiverRateLimit: receiverRateLimit,
|
||||
TrustedProxies: trustedProxies,
|
||||
AllowedEgressCIDRs: allowedEgressCIDRs,
|
||||
@@ -807,6 +846,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||
// disables idle expiry entirely, and that is worth showing
|
||||
// back to the operator.
|
||||
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
|
||||
"hstsMaxAge", s.HSTSMaxAge.String(),
|
||||
"receiverRateLimit", s.ReceiverRateLimit,
|
||||
"trustedProxies", len(s.TrustedProxies),
|
||||
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
@@ -70,7 +71,7 @@ func TestEnvironmentConfig(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if tt.envValue != "" {
|
||||
t.Setenv(
|
||||
@@ -196,7 +197,7 @@ func TestRetentionSweepInterval(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
@@ -335,7 +336,7 @@ func TestSessionIdleTimeout(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
@@ -378,6 +379,83 @@ func testSessionIdleTimeoutSuccess(
|
||||
assert.Equal(t, expected, cfg.SessionIdleTimeout)
|
||||
}
|
||||
|
||||
func TestHSTSMaxAge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
set bool
|
||||
value string
|
||||
expectError bool
|
||||
// sentinel, when set, must be wrapped by the startup
|
||||
// error; every error case must additionally name the
|
||||
// variable in its message.
|
||||
sentinel error
|
||||
expected time.Duration
|
||||
}{
|
||||
{
|
||||
name: caseUnsetUsesDefault,
|
||||
set: false,
|
||||
expected: 87600 * time.Hour,
|
||||
},
|
||||
{
|
||||
name: caseValidValueParsed,
|
||||
set: true,
|
||||
value: "720h",
|
||||
expected: 720 * time.Hour,
|
||||
},
|
||||
{
|
||||
// Zero turns the header off; it is not an error.
|
||||
name: "zero is accepted",
|
||||
set: true,
|
||||
value: "0",
|
||||
expected: 0,
|
||||
},
|
||||
{
|
||||
name: caseUnparseableFails,
|
||||
set: true,
|
||||
value: "10 years",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
// A whole number of seconds, so only the sign is wrong.
|
||||
name: "negative whole seconds fail startup",
|
||||
set: true,
|
||||
value: "-1s",
|
||||
expectError: true,
|
||||
sentinel: config.ErrInvalidHSTSMaxAge,
|
||||
},
|
||||
{
|
||||
name: "fraction of a second fails startup",
|
||||
set: true,
|
||||
value: "1500ms",
|
||||
expectError: true,
|
||||
sentinel: config.ErrInvalidHSTSMaxAge,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
t.Setenv("HSTS_MAX_AGE", tt.value)
|
||||
}
|
||||
|
||||
if tt.expectError {
|
||||
expectStartupErrorFor(t, "HSTS_MAX_AGE", tt.sentinel)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := buildConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.expected, cfg.HSTSMaxAge)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultDataDir(t *testing.T) {
|
||||
for _, env := range []string{"", "dev", "prod"} {
|
||||
name := env
|
||||
@@ -388,7 +466,7 @@ func TestDefaultDataDir(t *testing.T) {
|
||||
t.Run("env="+name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if env != "" {
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
||||
@@ -433,7 +511,7 @@ func TestDataDirHelper(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if set != "" {
|
||||
t.Setenv("DATA_DIR", set)
|
||||
@@ -498,7 +576,7 @@ func TestReceiverRateLimit(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
@@ -614,7 +692,7 @@ func TestTrustedProxies(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
@@ -725,7 +803,7 @@ func TestAllowedEgressCIDRs(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
if tt.set {
|
||||
@@ -797,7 +875,7 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
||||
|
||||
if tt.allowed != "" {
|
||||
@@ -933,7 +1011,7 @@ func TestMetricsAuthConfig(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if tt.username.set {
|
||||
t.Setenv("METRICS_USERNAME", tt.username.value)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
package config
|
||||
// Package configtest holds test support for code that reads the
|
||||
// process environment.
|
||||
package configtest
|
||||
|
||||
import (
|
||||
"os"
|
||||
@@ -6,12 +8,12 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ClearEnvForTest unsets every variable in the process environment
|
||||
// ClearEnv unsets every variable in the process environment
|
||||
// for the rest of the test, so a test sees only the variables it sets
|
||||
// itself, not whatever the developer's shell exports. When the test
|
||||
// ends it leaves the environment exactly as it found it: each variable
|
||||
// it unset is put back, and any variable added since is removed.
|
||||
func ClearEnvForTest(t *testing.T) {
|
||||
func ClearEnv(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
present := make(map[string]bool)
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||
)
|
||||
|
||||
// dotEnvKey is a throwaway variable name the .env tests write and
|
||||
@@ -39,9 +40,9 @@ func writeDotEnv(t *testing.T, contents string) string {
|
||||
// normally rather than be refused for a file it was never meant to
|
||||
// have.
|
||||
//
|
||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
||||
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
||||
@@ -54,9 +55,9 @@ func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||
// reaches the environment, which is the whole reason the file is read
|
||||
// at all.
|
||||
//
|
||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
||||
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
||||
|
||||
@@ -82,9 +83,9 @@ func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
|
||||
// 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.
|
||||
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
path := writeDotEnv(
|
||||
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
||||
@@ -132,7 +133,7 @@ func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
|
||||
//
|
||||
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
@@ -120,7 +121,7 @@ func TestEnvBool(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if tt.set {
|
||||
t.Setenv(testEnvKey, tt.value)
|
||||
@@ -169,7 +170,7 @@ func runEnvIntCases(
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if tt.set {
|
||||
t.Setenv(testEnvKey, tt.value)
|
||||
@@ -310,7 +311,7 @@ func TestEnvBindAddress(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if tt.set {
|
||||
t.Setenv(testEnvKey, tt.value)
|
||||
@@ -476,7 +477,7 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
t.Setenv(tt.key, tt.value)
|
||||
|
||||
@@ -638,7 +639,7 @@ func sentryEnvValueCases() []badEnvValueCase {
|
||||
// break the legitimate unset case: absent variables still get their
|
||||
// documented defaults.
|
||||
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||
|
||||
cfg, err := buildConfig(t)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||
)
|
||||
|
||||
// envKeySentryDSN is the variable envSentryDSN reads in production.
|
||||
@@ -100,7 +101,7 @@ func TestEnvSentryDSN(t *testing.T) {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Cannot use t.Parallel() here because t.Setenv
|
||||
// is incompatible with parallel subtests.
|
||||
config.ClearEnvForTest(t)
|
||||
configtest.ClearEnv(t)
|
||||
|
||||
if tt.set {
|
||||
t.Setenv(envKeySentryDSN, tt.value)
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/banner"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
@@ -249,12 +250,21 @@ func (d *Database) connectTo(dataDir string) error {
|
||||
return fmt.Errorf("migrating %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
err = parseModels(
|
||||
db, &Setting{}, &User{}, &APIKey{},
|
||||
&Webhook{}, &Entrypoint{}, &Target{},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting to %s: %w", dbPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrate applies the main database's SQL files. See
|
||||
// internal/db/migrations.
|
||||
func (d *Database) migrate() error {
|
||||
// Run GORM auto-migrations
|
||||
err := d.Migrate()
|
||||
err := migrations.Apply(d.db, migrations.Main)
|
||||
if err != nil {
|
||||
d.log.Error(
|
||||
"failed to run database migrations",
|
||||
@@ -269,6 +279,21 @@ func (d *Database) migrate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseModels has GORM parse each model for db before the handle is
|
||||
// shared. GORM parses a model the first time a handle uses it, and two
|
||||
// goroutines doing that at once for models that refer to each other,
|
||||
// as Webhook and Target do, race inside GORM.
|
||||
func parseModels(db *gorm.DB, models ...any) error {
|
||||
for _, model := range models {
|
||||
err := (&gorm.Statement{DB: db}).Parse(model)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parsing model %T: %w", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureAdminUser creates the bootstrap admin account when the
|
||||
// deployment has no users at all.
|
||||
func (d *Database) ensureAdminUser() error {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Package databasetest builds a WebhookDBManager for tests in other
|
||||
// packages.
|
||||
package databasetest
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx/fxtest"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// NewWebhookDBManager creates a WebhookDBManager backed by the given
|
||||
// data directory, logging at DEBUG to standard error.
|
||||
func NewWebhookDBManager(
|
||||
t *testing.T, dataDir string,
|
||||
) *database.WebhookDBManager {
|
||||
t.Helper()
|
||||
|
||||
return NewWebhookDBManagerWithLogger(
|
||||
t,
|
||||
dataDir,
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
)),
|
||||
)
|
||||
}
|
||||
|
||||
// NewWebhookDBManagerWithLogger is NewWebhookDBManager with the
|
||||
// logger supplied by the caller. The per-webhook databases this manager
|
||||
// opens hand that logger to gormlog, so a test that needs to see the SQL
|
||||
// the service emits can capture it.
|
||||
//
|
||||
// It is built through database.NewWebhookDBManager on a lifecycle that
|
||||
// is never started, so nothing closes its databases but the caller.
|
||||
func NewWebhookDBManagerWithLogger(
|
||||
t *testing.T, dataDir string, log *slog.Logger,
|
||||
) *database.WebhookDBManager {
|
||||
t.Helper()
|
||||
|
||||
mgr, err := database.NewWebhookDBManager(
|
||||
fxtest.NewLifecycle(t),
|
||||
database.WebhookDBManagerParams{
|
||||
Config: &config.Config{DataDir: dataDir},
|
||||
Logger: log,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return mgr
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -18,14 +17,14 @@ const omitAssociationsCallback = "webhooker:omit_associations"
|
||||
//
|
||||
// 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.
|
||||
// both in memory before writing. Without this guard GORM upserts those
|
||||
// parent rows here on the delivery and retry write paths, and the
|
||||
// target, whose config holds destination URLs and bearer credentials,
|
||||
// goes to a targets table this file does not have, which fails the
|
||||
// write. Registering the guard on the connection
|
||||
// covers every write path, including writes inside a transaction and
|
||||
// write paths added later. Every event-tier row this file holds is
|
||||
// written explicitly, so nothing depends on the automatic save.
|
||||
func omitAssociations(db *gorm.DB) error {
|
||||
omit := func(tx *gorm.DB) {
|
||||
tx.Statement.Omits = append(
|
||||
@@ -53,107 +52,3 @@ func omitAssociations(db *gorm.DB) error {
|
||||
|
||||
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,391 +1,15 @@
|
||||
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"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
)
|
||||
|
||||
// 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
|
||||
@@ -393,19 +17,19 @@ func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
|
||||
func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := eventDBDataDir(t)
|
||||
webhookID := uuid.New().String()
|
||||
|
||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
||||
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||
|
||||
db, err := mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: "leaky-target",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
|
||||
WebhookID: webhookID,
|
||||
Name: "leaky-target",
|
||||
Type: database.TargetTypeSlack,
|
||||
URL: "https://hooks.example/secret",
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
target.ID = uuid.New().String()
|
||||
|
||||
@@ -431,8 +55,9 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
||||
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))
|
||||
// The file has no targets table, so the create and update above
|
||||
// would have failed had GORM tried to save the target.
|
||||
assert.False(t, db.Migrator().HasTable(&database.Target{}))
|
||||
require.NoError(t, mgr.CloseAll())
|
||||
}
|
||||
|
||||
@@ -13,10 +13,8 @@ import (
|
||||
"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.
|
||||
// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that a new
|
||||
// per-webhook database has these indexes.
|
||||
func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -42,27 +40,9 @@ func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
|
||||
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)
|
||||
"a new database should have %s", ix.name)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,8 +79,9 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
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.
|
||||
// The delivery engine: the recovery routine, which reads the
|
||||
// retrying deliveries and, on a tick of the sweep, the pending ones
|
||||
// untouched for a while; and the queue depth count.
|
||||
assertPlanUses(t, db, dry.Where(
|
||||
"status = ?", database.DeliveryStatusRetrying,
|
||||
).Find(&deliveries), byStatus)
|
||||
@@ -149,6 +130,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
||||
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
|
||||
|
||||
@@ -30,8 +30,10 @@ type Event struct {
|
||||
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. RawQuery is the receiving request's query string
|
||||
// as sent, without the leading "?".
|
||||
Method string `gorm:"not null" json:"method"`
|
||||
RawQuery string `gorm:"type:text" json:"rawQuery"`
|
||||
Headers string `gorm:"type:text" json:"headers"` // JSON
|
||||
Body string `gorm:"type:text" json:"body"`
|
||||
ContentType string `json:"contentType"`
|
||||
@@ -43,8 +45,7 @@ type Event struct {
|
||||
|
||||
// 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
|
||||
// the receiver. It is not a foreign key: the source event can be
|
||||
// reaped by retention while its copies remain, and the id is
|
||||
// kept as the record of where the copy came from either way.
|
||||
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||
|
||||
@@ -23,8 +23,9 @@ func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
|
||||
Name: testWebhookName,
|
||||
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
|
||||
Targets: []database.Target{{
|
||||
Name: "log",
|
||||
Type: database.TargetTypeLog,
|
||||
Name: "log",
|
||||
Type: database.TargetTypeLog,
|
||||
MaxAttempts: 1,
|
||||
}},
|
||||
}
|
||||
require.NoError(t, db.Create(&stored).Error)
|
||||
|
||||
@@ -29,8 +29,10 @@ func marshalModel(t *testing.T, v any) string {
|
||||
// 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.
|
||||
// - Target.URL is an incoming-webhook URL whose path segments are
|
||||
// the bearer token.
|
||||
// - Target.Headers holds header values, routinely authorization
|
||||
// tokens.
|
||||
// - APIKey.Key is a bearer token outright.
|
||||
// - Setting.Value holds the session encryption key.
|
||||
// - User.Password holds the Argon2 hash, and was already tagged.
|
||||
@@ -44,11 +46,21 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
||||
model any
|
||||
}{
|
||||
{
|
||||
name: "target config",
|
||||
name: "target url",
|
||||
model: database.Target{
|
||||
Name: keptField,
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
||||
Name: keptField,
|
||||
Type: database.TargetTypeSlack,
|
||||
URL: "https://h/s/" + marker,
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "target headers",
|
||||
model: database.Target{
|
||||
Name: keptField,
|
||||
Type: database.TargetTypeHTTP,
|
||||
Headers: map[string]string{"Authorization": marker},
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -86,10 +98,10 @@ func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
|
||||
// TestWebhookMarshalsNoTargetURL 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) {
|
||||
func TestWebhookMarshalsNoTargetURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const marker = "QQNESTEDMARKERQQ"
|
||||
@@ -97,8 +109,9 @@ func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
|
||||
encoded := marshalModel(t, database.Webhook{
|
||||
Name: keptField,
|
||||
Targets: []database.Target{{
|
||||
Name: "slack",
|
||||
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
|
||||
Name: "slack",
|
||||
URL: "https://h/s/" + marker,
|
||||
MaxAttempts: 1,
|
||||
}},
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package database
|
||||
|
||||
import "time"
|
||||
|
||||
// TargetType represents the type of delivery target
|
||||
type TargetType string
|
||||
|
||||
@@ -11,7 +13,9 @@ const (
|
||||
TargetTypeSlack TargetType = "slack"
|
||||
)
|
||||
|
||||
// Target represents a delivery target for a webhook
|
||||
// Target represents a delivery target for a webhook. Its settings
|
||||
// are the fields from URL to ArchiveRotation; each type uses some of
|
||||
// them, and the ones a type does not use stay empty.
|
||||
type Target struct {
|
||||
BaseModel
|
||||
|
||||
@@ -20,18 +24,42 @@ type Target struct {
|
||||
Type TargetType `gorm:"not null" json:"type"`
|
||||
Active bool `gorm:"default:true" json:"active"`
|
||||
|
||||
// Configuration fields (JSON stored based on type).
|
||||
// URL is where an http or slack target sends each event.
|
||||
//
|
||||
// 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
|
||||
// json:"-" because the URL is 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.
|
||||
URL string `json:"-"`
|
||||
|
||||
// For HTTP targets (max_retries=0 means fire-and-forget,
|
||||
// >0 enables retries with backoff)
|
||||
MaxRetries int `json:"maxRetries,omitempty"`
|
||||
// Headers is an http target's extra request headers, name to
|
||||
// value, stored as JSON text. json:"-" for the same reason as URL:
|
||||
// a header value is routinely an authorization token.
|
||||
Headers map[string]string `gorm:"serializer:json" json:"-"`
|
||||
|
||||
// TimeoutSeconds is an http target's per-request timeout; 0 uses
|
||||
// the delivery engine's shared client timeout.
|
||||
TimeoutSeconds int `json:"timeoutSeconds,omitempty"`
|
||||
|
||||
// ForwardQuery passes each event's query string on to an http
|
||||
// target, appended to URL. Off, URL is sent exactly as stored.
|
||||
ForwardQuery bool `json:"forwardQuery,omitempty"`
|
||||
|
||||
// ArchiveExpiry is how long a database target keeps each archived
|
||||
// event; 0 keeps them forever.
|
||||
ArchiveExpiry time.Duration `json:"archiveExpiry,omitempty"`
|
||||
|
||||
// ArchiveRotation is how often a database target starts a new
|
||||
// archive file: none, monthly, daily or hourly.
|
||||
ArchiveRotation string `json:"archiveRotation,omitempty"`
|
||||
|
||||
// MaxAttempts is how many times an HTTP or Slack target attempts
|
||||
// each delivery in all, at least 1. 1 is a single attempt with no
|
||||
// circuit breaker; above 1, a failed attempt is retried with
|
||||
// backoff behind the breaker. The table's check constraint refuses
|
||||
// a target saved with less than 1, whatever its type.
|
||||
MaxAttempts int `gorm:"not null;check:max_attempts >= 1" json:"maxAttempts,omitempty"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// Webhook.Targets leads back here, and the JSON could loop.
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// newHTTPTarget returns an http target that makes maxAttempts attempts
|
||||
// at each delivery.
|
||||
func newHTTPTarget(maxAttempts int) *database.Target {
|
||||
return &database.Target{
|
||||
WebhookID: uuid.New().String(),
|
||||
Name: "target",
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: "https://example.com/",
|
||||
MaxAttempts: maxAttempts,
|
||||
}
|
||||
}
|
||||
|
||||
// TestTargetCreate_RefusesZeroAttempts relies on the targets table
|
||||
// alone: nothing in the model checks the number, so the check
|
||||
// constraint is what keeps a target with 0 attempts out of the
|
||||
// database.
|
||||
func TestTargetCreate_RefusesZeroAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
err := db.Create(newHTTPTarget(0)).Error
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "CHECK constraint failed")
|
||||
}
|
||||
|
||||
func TestTargetCreate_AcceptsOneAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
require.NoError(t, db.Create(newHTTPTarget(1)).Error)
|
||||
}
|
||||
|
||||
// TestTargetHeaders_StoredAsJSON reads the headers column back as
|
||||
// text, to show it holds the header map as JSON.
|
||||
func TestTargetHeaders_StoredAsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
target := newHTTPTarget(1)
|
||||
target.Headers = map[string]string{"Authorization": "Bearer x"}
|
||||
require.NoError(t, db.Create(target).Error)
|
||||
|
||||
var headers string
|
||||
|
||||
require.NoError(t, db.Raw(
|
||||
"SELECT headers FROM targets WHERE id = ?", target.ID,
|
||||
).Scan(&headers).Error)
|
||||
assert.JSONEq(t, `{"Authorization":"Bearer x"}`, headers)
|
||||
}
|
||||
@@ -4,27 +4,13 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
// DefaultRetentionDays is the event retention period applied to a
|
||||
// webhook created without an explicit retention value. It is the
|
||||
// single source of truth for that policy and must stay in sync
|
||||
// with the `gorm:"default:30"` column default on
|
||||
// Webhook.RetentionDays below; a struct tag cannot reference a
|
||||
// constant, so a test asserts the two agree.
|
||||
// DefaultRetentionDays is the event retention period the new
|
||||
// webhook form applies when its retention field is left blank.
|
||||
DefaultRetentionDays = 30
|
||||
|
||||
// RetentionForeverDays is the sentinel RetentionDays value meaning
|
||||
// "retain events forever". Users express that intent as 0, which
|
||||
// Webhook.BeforeSave rewrites to this value: the column default
|
||||
// substitutes DefaultRetentionDays for a zero value at insert
|
||||
// time, so a zero can never survive a round trip to the database.
|
||||
// Nothing outside this file may hardcode the number.
|
||||
RetentionForeverDays = 365 * 1000
|
||||
|
||||
// MaxFiniteRetentionDays is the largest finite retention period the
|
||||
// reaper's cutoff arithmetic can represent, and therefore the
|
||||
// largest one a caller may request. It is derived from that
|
||||
@@ -38,9 +24,7 @@ const (
|
||||
// turns the cutoff into a timestamp in the far future that matches
|
||||
// every row in the webhook's database. That is why this bound is
|
||||
// enforced on input and why retentionCutoff saturates underneath
|
||||
// it. Note that RetentionForeverDays deliberately sits above this
|
||||
// ceiling: such webhooks are skipped before any cutoff is
|
||||
// computed, and never reach the arithmetic at all.
|
||||
// it.
|
||||
MaxFiniteRetentionDays = int(
|
||||
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
|
||||
)
|
||||
@@ -48,12 +32,10 @@ const (
|
||||
|
||||
// Webhook represents a webhook processing unit that groups entrypoints and targets
|
||||
//
|
||||
// Every method below takes a pointer receiver. BeforeSave has to,
|
||||
// because it mutates the record and GORM only invokes hooks declared
|
||||
// that way; the display helpers follow suit so the receiver kinds do
|
||||
// not mix. Handlers therefore put a *Webhook into template data:
|
||||
// html/template cannot call a pointer method on a value held in a map,
|
||||
// because a map element is not addressable.
|
||||
// Every method below takes a pointer receiver, so handlers put a
|
||||
// *Webhook into template data: html/template cannot call a pointer
|
||||
// method on a value held in a map, because a map element is not
|
||||
// addressable.
|
||||
type Webhook struct {
|
||||
BaseModel
|
||||
|
||||
@@ -61,10 +43,9 @@ type Webhook struct {
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
// RetentionDays is the number of days to retain events. A value of
|
||||
// RetentionForeverDays means retain forever. The column default
|
||||
// must equal DefaultRetentionDays.
|
||||
RetentionDays int `gorm:"default:30" json:"retentionDays"`
|
||||
// RetentionDays is the number of days to retain events, and 0
|
||||
// means retain them forever.
|
||||
RetentionDays int `json:"retentionDays"`
|
||||
|
||||
// Relations. No model marshals the record it belongs to:
|
||||
// User.Webhooks leads back here, and the JSON could loop.
|
||||
@@ -73,36 +54,17 @@ type Webhook struct {
|
||||
Targets []Target `json:"targets,omitempty"`
|
||||
}
|
||||
|
||||
// BeforeSave normalises RetentionDays on every insert and update. A
|
||||
// non-positive value is the user's way of asking for "retain forever",
|
||||
// which is stored as the RetentionForeverDays sentinel.
|
||||
//
|
||||
// This has to happen in a hook rather than at the call sites. GORM
|
||||
// substitutes the column default (DefaultRetentionDays) for a zero
|
||||
// value while building the insert statement, which runs after
|
||||
// BeforeSave; rewriting any later than this loses that race and the
|
||||
// row lands at 30 days. Living on the model also means a future call
|
||||
// site — a REST API, a fixture, a migration — cannot bypass it.
|
||||
func (w *Webhook) BeforeSave(_ *gorm.DB) error {
|
||||
if w.RetentionDays <= 0 {
|
||||
w.RetentionDays = RetentionForeverDays
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// retainsForever reports whether a stored RetentionDays value means
|
||||
// "keep events indefinitely". It is the single definition of that
|
||||
// question, shared by Webhook.RetainsForever and by the reaper's
|
||||
// retainsForever reports whether a RetentionDays value means "keep
|
||||
// events indefinitely", which 0 does. It is the single definition of
|
||||
// that question, shared by Webhook.RetainsForever and by the reaper's
|
||||
// cutoff computation so the two cannot disagree about which webhooks
|
||||
// are exempt from reaping.
|
||||
//
|
||||
// It accepts the RetentionForeverDays sentinel written by BeforeSave
|
||||
// and, defensively, the non-positive values that rows written before
|
||||
// the sentinel existed may still carry.
|
||||
// A negative value, which the forms refuse, is read the same way: as a
|
||||
// day count it would put the cutoff in the future, where it matches
|
||||
// every event the webhook has.
|
||||
func retainsForever(retentionDays int) bool {
|
||||
return retentionDays <= 0 ||
|
||||
retentionDays >= RetentionForeverDays
|
||||
return retentionDays <= 0
|
||||
}
|
||||
|
||||
// RetainsForever reports whether this webhook's events are kept
|
||||
@@ -119,7 +81,8 @@ func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
|
||||
}
|
||||
|
||||
// RetentionLabel returns the webhook's retention policy as display
|
||||
// text, so that no template has to know about the sentinel value.
|
||||
// text: "forever" rather than "0 days" for a webhook that keeps its
|
||||
// events.
|
||||
func (w *Webhook) RetentionLabel() string {
|
||||
if w.RetainsForever() {
|
||||
return "forever"
|
||||
|
||||
@@ -2,8 +2,6 @@ package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -47,8 +45,7 @@ func storedRetention(t *testing.T, db *gorm.DB, id string) int {
|
||||
}
|
||||
|
||||
// newWebhookWithRetention creates a webhook through the ordinary Create
|
||||
// path, so the BeforeSave hook and the GORM column default both apply
|
||||
// exactly as they do in production.
|
||||
// path, as the new webhook form does.
|
||||
func newWebhookWithRetention(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
@@ -67,7 +64,7 @@ func newWebhookWithRetention(
|
||||
return wh.ID
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
|
||||
func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
@@ -75,31 +72,10 @@ func TestWebhookBeforeSave_ZeroBecomesForeverSentinel(t *testing.T) {
|
||||
wh := &database.Webhook{RetentionDays: 0}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
"a zero retention must be stored as the sentinel, "+
|
||||
"not replaced by the column default",
|
||||
)
|
||||
assert.Equal(t, 0, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_NegativeBecomesForeverSentinel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
|
||||
wh := &database.Webhook{RetentionDays: -5}
|
||||
id := newWebhookWithRetention(t, db, wh)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
|
||||
func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
@@ -110,10 +86,9 @@ func TestWebhookBeforeSave_PositiveIsPreserved(t *testing.T) {
|
||||
assert.Equal(t, 7, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
// TestWebhookBeforeSave_UpdateToZeroBecomesSentinel proves the hook
|
||||
// fires on update as well as insert, via the same Save call the edit
|
||||
// handler makes.
|
||||
func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
|
||||
// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day
|
||||
// count to 0 through the same Save call the edit handler makes.
|
||||
func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := startedTestDB(t)
|
||||
@@ -125,28 +100,7 @@ func TestWebhookBeforeSave_UpdateToZeroBecomesSentinel(t *testing.T) {
|
||||
wh.RetentionDays = 0
|
||||
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
storedRetention(t, db, id),
|
||||
)
|
||||
}
|
||||
|
||||
// TestWebhookRetentionColumnDefaultMatchesConstant guards the one place
|
||||
// the default lives twice: a struct tag cannot reference a constant, so
|
||||
// this asserts the tag and DefaultRetentionDays agree.
|
||||
func TestWebhookRetentionColumnDefaultMatchesConstant(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
field, ok := reflect.TypeFor[database.Webhook]().
|
||||
FieldByName("RetentionDays")
|
||||
require.True(t, ok, "Webhook.RetentionDays must exist")
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
"default:"+strconv.Itoa(database.DefaultRetentionDays),
|
||||
field.Tag.Get("gorm"),
|
||||
)
|
||||
assert.Equal(t, 0, storedRetention(t, db, id))
|
||||
}
|
||||
|
||||
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
|
||||
@@ -176,14 +130,6 @@ func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
|
||||
time.Duration(overCeiling*hoursPerDay)*time.Hour,
|
||||
"one day past the ceiling must overflow",
|
||||
)
|
||||
|
||||
assert.Less(
|
||||
t,
|
||||
database.MaxFiniteRetentionDays,
|
||||
database.RetentionForeverDays,
|
||||
"the sentinel sits above the ceiling and is only safe "+
|
||||
"because retain-forever webhooks skip the arithmetic",
|
||||
)
|
||||
}
|
||||
|
||||
func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
||||
@@ -195,16 +141,8 @@ func TestWebhookRetainsForeverAndLabel(t *testing.T) {
|
||||
forever bool
|
||||
label string
|
||||
}{
|
||||
{
|
||||
"sentinel",
|
||||
database.RetentionForeverDays, true, testForeverLabel,
|
||||
},
|
||||
{
|
||||
"above sentinel",
|
||||
database.RetentionForeverDays + 1, true, testForeverLabel,
|
||||
},
|
||||
{"legacy zero", 0, true, testForeverLabel},
|
||||
{"legacy negative", -1, true, testForeverLabel},
|
||||
{"zero", 0, true, testForeverLabel},
|
||||
{"negative", -1, true, testForeverLabel},
|
||||
{"default", database.DefaultRetentionDays, false, "30 days"},
|
||||
{"one day", 1, false, "1 day"},
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package database
|
||||
|
||||
// Migrate runs database migrations for the main application database.
|
||||
// Only configuration-tier models are stored in the main database.
|
||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||
// TargetTotals, EntrypointTotals) live in
|
||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||
func (d *Database) Migrate() error {
|
||||
return d.db.AutoMigrate(
|
||||
&Setting{},
|
||||
&User{},
|
||||
&APIKey{},
|
||||
&Webhook{},
|
||||
&Entrypoint{},
|
||||
&Target{},
|
||||
)
|
||||
}
|
||||
@@ -184,8 +184,9 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
||||
|
||||
wh := webhooks[i]
|
||||
|
||||
// A missing database has nothing to reap. Restart recovery
|
||||
// reports a lost one (see WebhookDBManager.GetDB).
|
||||
// A missing database has nothing to reap. The delivery
|
||||
// engine's recovery routine reports a lost one at start (see
|
||||
// WebhookDBManager.GetDB).
|
||||
if !r.dbManager.DBExists(wh.ID) {
|
||||
continue
|
||||
}
|
||||
@@ -243,7 +244,8 @@ func (r *RetentionReaper) reapWebhook(
|
||||
|
||||
// retentionCutoff returns the timestamp before which a webhook's
|
||||
// events have expired, and whether any cutoff applies at all. It
|
||||
// reports false for a retain-forever policy, so no DELETE is issued.
|
||||
// reports false for a retention of 0, which means retain forever, so
|
||||
// no DELETE is issued.
|
||||
//
|
||||
// The day count is clamped to MaxFiniteRetentionDays first. This is
|
||||
// defense in depth rather than decoration: a time.Duration is an int64
|
||||
@@ -253,7 +255,7 @@ func (r *RetentionReaper) reapWebhook(
|
||||
// row in the database: the sweep then deletes every event, delivery,
|
||||
// and delivery result, including ones created seconds ago. Rejecting
|
||||
// out-of-range input at the form is the primary guard; saturating here
|
||||
// means an old row, a migration, or a future call site cannot turn a
|
||||
// means a value that reaches the row by any other path cannot turn a
|
||||
// too-large retention into total data loss.
|
||||
func retentionCutoff(
|
||||
now time.Time,
|
||||
|
||||
@@ -51,7 +51,7 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv {
|
||||
|
||||
mgr, err := database.NewWebhookDBManager(
|
||||
lc,
|
||||
database.WebhookDBManagerParams{Config: cfg, Logger: l},
|
||||
database.WebhookDBManagerParams{Config: cfg, Logger: l.Get()},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -85,41 +85,6 @@ func createWebhook(
|
||||
db.Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
|
||||
// Webhook.BeforeSave rewrites a non-positive RetentionDays to the
|
||||
// retain-forever sentinel, and the column's GORM default would
|
||||
// otherwise substitute 30. Force the requested value with a
|
||||
// column-level update so tests can plant legacy rows that predate
|
||||
// the sentinel and still carry a literal 0 or negative value.
|
||||
require.NoError(
|
||||
t,
|
||||
db.Model(wh).
|
||||
Update("retention_days", retentionDays).Error,
|
||||
)
|
||||
|
||||
return wh.ID
|
||||
}
|
||||
|
||||
// createWebhookNormally inserts a webhook through the ordinary Create
|
||||
// path, with no column-level forcing, so Webhook.BeforeSave applies
|
||||
// exactly as it does in production. Passing 0 therefore yields a row
|
||||
// holding the RetentionForeverDays sentinel.
|
||||
func createWebhookNormally(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
retentionDays int,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
wh := &database.Webhook{
|
||||
UserID: uuid.New().String(),
|
||||
Name: testWebhookName,
|
||||
RetentionDays: retentionDays,
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
db.Omit(clause.Associations).Create(wh).Error,
|
||||
)
|
||||
|
||||
return wh.ID
|
||||
}
|
||||
|
||||
@@ -281,19 +246,19 @@ func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
|
||||
assertChainPresent(t, db, recent)
|
||||
}
|
||||
|
||||
// TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep covers the
|
||||
// end-to-end retain-forever path: a webhook created the normal way with
|
||||
// a requested retention of 0 lands on the RetentionForeverDays
|
||||
// sentinel, and the reaper leaves its ancient events alone while still
|
||||
// reaping a finite-retention webhook in the very same sweep.
|
||||
func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||
// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the
|
||||
// end-to-end retain-forever path: a webhook created with a retention
|
||||
// of 0 reads back 0, and the reaper leaves its ancient events alone
|
||||
// while still reaping a finite-retention webhook in the very same
|
||||
// sweep.
|
||||
func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
foreverID := createWebhookNormally(t, env.mainDB.DB(), 0)
|
||||
foreverID := createWebhook(t, env.mainDB.DB(), 0)
|
||||
|
||||
var stored database.Webhook
|
||||
|
||||
@@ -302,14 +267,12 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||
env.mainDB.DB().Where("id = ?", foreverID).
|
||||
First(&stored).Error,
|
||||
)
|
||||
require.Equal(
|
||||
t,
|
||||
database.RetentionForeverDays,
|
||||
stored.RetentionDays,
|
||||
"a requested retention of 0 must persist as the sentinel",
|
||||
require.Zero(
|
||||
t, stored.RetentionDays,
|
||||
"a retention of 0 must be stored as 0",
|
||||
)
|
||||
|
||||
finiteID := createWebhookNormally(t, env.mainDB.DB(), 30)
|
||||
finiteID := createWebhook(t, env.mainDB.DB(), 30)
|
||||
|
||||
foreverDB, err := env.mgr.GetDB(foreverID)
|
||||
require.NoError(t, err)
|
||||
@@ -335,14 +298,10 @@ func TestRetentionReaper_SkipsSentinelReapsFiniteInSameSweep(
|
||||
// negative, so subtracting it moves the cutoff into the far future,
|
||||
// where "created_at < cutoff" matches every row: an event created a
|
||||
// moment ago, and its delivery and delivery result, were all deleted
|
||||
// on the first sweep. 200000 is inside that band and below the
|
||||
// retain-forever sentinel, so it is treated as a finite policy and
|
||||
// really does reach the arithmetic.
|
||||
// on the first sweep. 200000 is inside that band.
|
||||
//
|
||||
// The row is planted at the column level because such a value can no
|
||||
// longer be submitted through the form; the point of the test is that
|
||||
// a row from an older version, or a future call site, still cannot
|
||||
// trigger the wipe.
|
||||
// The form refuses such a value; the point of the test is that a row
|
||||
// written by any other path still cannot trigger the wipe.
|
||||
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -358,12 +317,6 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
database.MaxFiniteRetentionDays,
|
||||
"the test value must exceed what the cutoff can represent",
|
||||
)
|
||||
require.Less(
|
||||
t,
|
||||
overflowingRetentionDays,
|
||||
database.RetentionForeverDays,
|
||||
"the test value must not be treated as retain-forever",
|
||||
)
|
||||
|
||||
webhookID := createWebhook(
|
||||
t, env.mainDB.DB(), overflowingRetentionDays,
|
||||
@@ -379,14 +332,15 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
|
||||
assertChainPresent(t, db, fresh)
|
||||
}
|
||||
|
||||
func TestRetentionReaper_RetainsForeverWhenNonPositive(t *testing.T) {
|
||||
// TestRetentionReaper_RetainsForeverWhenNegative checks that a negative
|
||||
// retention, which the forms refuse, keeps every event rather than
|
||||
// putting the cutoff in the future, where it would match them all.
|
||||
func TestRetentionReaper_RetainsForeverWhenNegative(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupRetentionTest(t)
|
||||
|
||||
// A legacy row written before the sentinel existed still carries a
|
||||
// literal 0; the <= 0 guard must keep honouring it.
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), 0)
|
||||
webhookID := createWebhook(t, env.mainDB.DB(), -1)
|
||||
|
||||
db, err := env.mgr.GetDB(webhookID)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
)
|
||||
|
||||
// withFixedNow returns db with GORM's clock stopped at a time in UTC,
|
||||
// so that the created and updated times it writes come back from
|
||||
// SQLite equal to the row written, which holds them in UTC.
|
||||
func withFixedNow(db *gorm.DB) (*gorm.DB, time.Time) {
|
||||
at := time.Date(2026, 10, 13, 12, 30, 45, 123456789, time.UTC)
|
||||
|
||||
return db.Session(&gorm.Session{
|
||||
NowFunc: func() time.Time { return at },
|
||||
}), at
|
||||
}
|
||||
|
||||
// newBaseModel returns a BaseModel with a new ID.
|
||||
func newBaseModel() database.BaseModel {
|
||||
return database.BaseModel{ID: uuid.New().String()}
|
||||
}
|
||||
|
||||
// assertRoundTrip writes row, which must be the only row of its
|
||||
// table, and reads back the same row.
|
||||
func assertRoundTrip[T any](t *testing.T, db *gorm.DB, row *T) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, db.Create(row).Error)
|
||||
|
||||
var got T
|
||||
|
||||
require.NoError(t, db.First(&got).Error)
|
||||
assert.Equal(t, *row, got)
|
||||
}
|
||||
|
||||
// TestMainSchemaHoldsEveryModel writes a row of each main database
|
||||
// model, every field set, into a database made from its SQL files.
|
||||
func TestMainSchemaHoldsEveryModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mainDB, err := database.Open(
|
||||
t.TempDir(), slog.New(slog.DiscardHandler),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = mainDB.Close() })
|
||||
|
||||
db, at := withFixedNow(mainDB.DB())
|
||||
|
||||
assertRoundTrip(t, db, &database.Setting{
|
||||
Key: "session_key", Value: "value",
|
||||
})
|
||||
assertRoundTrip(t, db, &database.User{
|
||||
BaseModel: newBaseModel(),
|
||||
Username: "user",
|
||||
Password: "password hash",
|
||||
})
|
||||
assertRoundTrip(t, db, &database.APIKey{
|
||||
BaseModel: newBaseModel(),
|
||||
UserID: uuid.New().String(),
|
||||
Key: "key",
|
||||
Description: "API key description",
|
||||
LastUsedAt: &at,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.Webhook{
|
||||
BaseModel: newBaseModel(),
|
||||
UserID: uuid.New().String(),
|
||||
Name: "webhook",
|
||||
Description: "webhook description",
|
||||
RetentionDays: 7,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.Entrypoint{
|
||||
BaseModel: newBaseModel(),
|
||||
WebhookID: uuid.New().String(),
|
||||
Path: uuid.New().String(),
|
||||
Description: "entrypoint description",
|
||||
Active: true,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.Target{
|
||||
BaseModel: newBaseModel(),
|
||||
WebhookID: uuid.New().String(),
|
||||
Name: "target",
|
||||
Type: database.TargetTypeHTTP,
|
||||
Active: true,
|
||||
URL: "https://example.com/",
|
||||
Headers: map[string]string{"X-Api-Key": "key"},
|
||||
TimeoutSeconds: 30,
|
||||
ForwardQuery: true,
|
||||
ArchiveExpiry: 720 * time.Hour,
|
||||
ArchiveRotation: "daily",
|
||||
MaxAttempts: 3,
|
||||
})
|
||||
}
|
||||
|
||||
// TestEventSchemaHoldsEveryModel does the same for a webhook's event
|
||||
// database, which the manager makes from its SQL files.
|
||||
func TestEventSchemaHoldsEveryModel(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||
|
||||
t.Cleanup(func() { _ = mgr.CloseAll() })
|
||||
|
||||
events, err := mgr.GetDB(uuid.New().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
db, at := withFixedNow(events)
|
||||
source := uuid.New().String()
|
||||
|
||||
assertRoundTrip(t, db, &database.Event{
|
||||
BaseModel: newBaseModel(),
|
||||
WebhookID: uuid.New().String(),
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: http.MethodPost,
|
||||
RawQuery: "a=1",
|
||||
Headers: `{"X":"Y"}`,
|
||||
Body: "body",
|
||||
ContentType: "text/plain",
|
||||
BodyBytes: 4,
|
||||
ResubmittedFromID: &source,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.Delivery{
|
||||
BaseModel: newBaseModel(),
|
||||
EventID: uuid.New().String(),
|
||||
TargetID: uuid.New().String(),
|
||||
Status: database.DeliveryStatusDelivered,
|
||||
FinishedAt: &at,
|
||||
Replay: true,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.DeliveryResult{
|
||||
BaseModel: newBaseModel(),
|
||||
DeliveryID: uuid.New().String(),
|
||||
AttemptNum: 2,
|
||||
Success: true,
|
||||
StatusCode: 200,
|
||||
ResponseBody: "ok",
|
||||
Error: "error",
|
||||
Duration: 15,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.TargetTotals{
|
||||
TargetID: uuid.New().String(),
|
||||
Deliveries: 5,
|
||||
Delivered: 3,
|
||||
Failed: 1,
|
||||
DeliveriesRemoved: 2,
|
||||
FailedRemoved: 1,
|
||||
})
|
||||
assertRoundTrip(t, db, &database.EntrypointTotals{
|
||||
EntrypointID: uuid.New().String(), LastEventAt: at,
|
||||
})
|
||||
|
||||
// A new event database has its one row of event totals already.
|
||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
||||
Events: 3, EventsRemoved: 1, LastEventAt: &at,
|
||||
}))
|
||||
|
||||
var totals []database.EventTotals
|
||||
|
||||
require.NoError(t, db.Find(&totals).Error)
|
||||
assert.Equal(t, []database.EventTotals{{
|
||||
ID: 1, Events: 3, EventsRemoved: 1, LastEventAt: &at,
|
||||
}}, totals)
|
||||
}
|
||||
@@ -137,7 +137,7 @@ func TestPerWebhookEventDatabaseFilesAreOwnerOnly(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier.
|
||||
// internal/delivery builds that path and opens it through OpenSQLite,
|
||||
// internal/archive builds that path and opens it through OpenSQLite,
|
||||
// the same single open path exercised here, so the mode is settled for
|
||||
// all three tiers in one place.
|
||||
func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) {
|
||||
|
||||
@@ -118,9 +118,8 @@ const SQLiteFilePerm fs.FileMode = 0o600
|
||||
// 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.
|
||||
// The mode is set on every open, whoever created the file, so a
|
||||
// database or sidecar found at a wider mode is tightened too.
|
||||
func reserveSQLiteFile(path string, create bool) error {
|
||||
if create {
|
||||
// gosec G304: the path is the database file the caller asked
|
||||
@@ -242,7 +241,19 @@ func SQLiteDSN(path, mode string) string {
|
||||
)
|
||||
q.Add("_pragma", "journal_mode(WAL)")
|
||||
|
||||
return "file:" + path + "?" + q.Encode()
|
||||
// SQLite reads a file: URI's path only up to the first ? or #, and
|
||||
// decodes %XX escapes in it, so url.URL escapes the path. OmitHost
|
||||
// keeps a relative path as "file:data/x.db"; written
|
||||
// "file://data/x.db", SQLite would reject "data" as the URI's
|
||||
// authority.
|
||||
dsn := url.URL{
|
||||
Scheme: "file",
|
||||
Path: path,
|
||||
OmitHost: true,
|
||||
RawQuery: q.Encode(),
|
||||
}
|
||||
|
||||
return dsn.String()
|
||||
}
|
||||
|
||||
// OpenSQLite opens the SQLite file at path with the service's
|
||||
|
||||
@@ -2,6 +2,8 @@ package database_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -112,6 +114,79 @@ func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) {
|
||||
assert.Equal(t, "10000", busy)
|
||||
}
|
||||
|
||||
// TestOpenSQLiteOpensThePathAskedFor uses a data directory whose name
|
||||
// holds ?, # and %. SQLite reads a file: URI's path only up to a ? or
|
||||
// #, and decodes %41 to A, so an unescaped path sent every database in
|
||||
// that directory to one file outside it.
|
||||
func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := filepath.Join(t.TempDir(), "hooks#1?x%41")
|
||||
require.NoError(t, os.Mkdir(dir, 0o700))
|
||||
|
||||
for _, name := range []string{
|
||||
database.MainDBFileName,
|
||||
"events-" + uuid.New().String() + ".db",
|
||||
} {
|
||||
path := filepath.Join(dir, name)
|
||||
|
||||
sqlDB, err := database.OpenSQLite(
|
||||
path, database.SQLiteModeCreate,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) })
|
||||
|
||||
assertOpenedFile(t, sqlDB, path)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOpenSQLiteOpensARelativePath opens a database at a path relative
|
||||
// to the working directory, which is what a relative DATA_DIR such as
|
||||
// the README's DATA_DIR=../webhooker-data gives OpenSQLite.
|
||||
//
|
||||
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||
func TestOpenSQLiteOpensARelativePath(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
require.NoError(t, os.Mkdir("data", 0o700))
|
||||
|
||||
path := filepath.Join("data", database.MainDBFileName)
|
||||
|
||||
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { require.NoError(t, sqlDB.Close()) }()
|
||||
|
||||
assertOpenedFile(t, sqlDB, path)
|
||||
}
|
||||
|
||||
// assertOpenedFile checks that sqlDB is the SQLite file at path.
|
||||
// database_list names the file SQLite actually opened, as an absolute
|
||||
// path with symlinks resolved, so the files are compared, not the names.
|
||||
func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) {
|
||||
t.Helper()
|
||||
|
||||
var (
|
||||
seq int
|
||||
schema, file string
|
||||
)
|
||||
|
||||
require.NoError(t, sqlDB.
|
||||
QueryRowContext(t.Context(), "pragma database_list").
|
||||
Scan(&seq, &schema, &file))
|
||||
|
||||
asked, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
opened, err := os.Stat(file)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(
|
||||
t, os.SameFile(asked, opened),
|
||||
"asked for %s, SQLite opened %s", path, file,
|
||||
)
|
||||
}
|
||||
|
||||
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
|
||||
// reproduction in
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/256: an operator's
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB.
|
||||
// Intended for use in tests that need a *database.Database without the
|
||||
// full fx lifecycle. The caller is responsible for closing the underlying
|
||||
// sql.DB connection.
|
||||
func NewTestDatabase(db *gorm.DB) *Database {
|
||||
return &Database{
|
||||
db: db,
|
||||
log: slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&slog.HandlerOptions{Level: slog.LevelDebug},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// NewTestWebhookDBManager creates a WebhookDBManager backed by the given
|
||||
// data directory. Intended for use in tests without the fx lifecycle.
|
||||
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
|
||||
return NewTestWebhookDBManagerWithLogger(
|
||||
dataDir,
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
&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,
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/datadir"
|
||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
|
||||
// WebhookDBManagerParams holds the fx dependencies for
|
||||
@@ -24,7 +24,7 @@ type WebhookDBManagerParams struct {
|
||||
fx.In
|
||||
|
||||
Config *config.Config
|
||||
Logger *logger.Logger
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// errInvalidCachedDBType indicates a type assertion failure
|
||||
@@ -70,7 +70,7 @@ func NewWebhookDBManager(
|
||||
) (*WebhookDBManager, error) {
|
||||
m := &WebhookDBManager{
|
||||
dataDir: params.Config.DataDir,
|
||||
log: params.Logger.Get(),
|
||||
log: params.Logger,
|
||||
}
|
||||
|
||||
// Create data directory if it doesn't exist. datadir.DirPerm is the
|
||||
@@ -371,18 +371,7 @@ func (m *WebhookDBManager) openDB(
|
||||
)
|
||||
}
|
||||
|
||||
err = purgeTargetRows(db, m.log, webhookID)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Run migrations for event-tier models only
|
||||
err = db.AutoMigrate(
|
||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||
)
|
||||
err = migrations.Apply(db, migrations.Events)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
@@ -392,14 +381,15 @@ 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
|
||||
err = parseModels(
|
||||
db, &Event{}, &Delivery{}, &DeliveryResult{},
|
||||
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||
)
|
||||
if err != nil {
|
||||
_ = sqlDB.Close()
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"creating event totals for webhook database %s: %w",
|
||||
"connecting to webhook database %s: %w",
|
||||
webhookID, err,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/config"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/globals"
|
||||
"sneak.berlin/go/webhooker/internal/logger"
|
||||
)
|
||||
@@ -50,7 +51,7 @@ func setupTestWebhookDBManager(
|
||||
lc,
|
||||
database.WebhookDBManagerParams{
|
||||
Config: cfg,
|
||||
Logger: l,
|
||||
Logger: l.Get(),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
@@ -117,7 +118,8 @@ func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) {
|
||||
|
||||
var logs bytes.Buffer
|
||||
|
||||
mgr := database.NewTestWebhookDBManagerWithLogger(
|
||||
mgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||
t,
|
||||
t.TempDir(),
|
||||
slog.New(slog.NewTextHandler(&logs, nil)),
|
||||
)
|
||||
@@ -307,7 +309,8 @@ func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
|
||||
|
||||
var logs bytes.Buffer
|
||||
|
||||
mgr := database.NewTestWebhookDBManagerWithLogger(
|
||||
mgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||
t,
|
||||
t.TempDir(),
|
||||
slog.New(slog.NewTextHandler(&logs, nil)),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration 000: the table recording which migrations this file has
|
||||
-- had. Applied before the others; it records itself.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Migration 001: the schema of a database target's archive file. The
|
||||
-- archivedEvent model in internal/archive must agree with it.
|
||||
|
||||
CREATE TABLE archived_events (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
event_id text,
|
||||
webhook_id text,
|
||||
entrypoint_id text,
|
||||
method text,
|
||||
raw_query text,
|
||||
headers text,
|
||||
body text,
|
||||
content_type text,
|
||||
archived_at datetime
|
||||
);
|
||||
|
||||
CREATE INDEX idx_archived_events_event_id ON archived_events (event_id);
|
||||
CREATE INDEX idx_archived_events_archived_at ON archived_events (archived_at);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration 000: the table recording which migrations this file has
|
||||
-- had. Applied before the others; it records itself.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||
@@ -0,0 +1,98 @@
|
||||
-- Migration 001: the schema of a webhook's event database. The models
|
||||
-- in internal/database must agree with it. The webhook, entrypoint and
|
||||
-- target ids here name rows in the main database, which has those
|
||||
-- tables; this file does not.
|
||||
|
||||
CREATE TABLE events (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
webhook_id uuid NOT NULL,
|
||||
entrypoint_id uuid NOT NULL,
|
||||
method text NOT NULL,
|
||||
raw_query text,
|
||||
headers text,
|
||||
body text,
|
||||
content_type text,
|
||||
body_bytes integer NOT NULL,
|
||||
resubmitted_from_id uuid,
|
||||
PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_events_created_at ON events (created_at);
|
||||
CREATE INDEX idx_events_deleted_at ON events (deleted_at);
|
||||
CREATE INDEX idx_events_deleted_at_created_at
|
||||
ON events (deleted_at, created_at);
|
||||
CREATE INDEX idx_events_resubmitted_from_id
|
||||
ON events (resubmitted_from_id, deleted_at);
|
||||
CREATE INDEX idx_events_entrypoint_id
|
||||
ON events (entrypoint_id, deleted_at, resubmitted_from_id, created_at);
|
||||
|
||||
CREATE TABLE deliveries (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
event_id uuid NOT NULL,
|
||||
target_id uuid NOT NULL,
|
||||
status text NOT NULL DEFAULT 'pending',
|
||||
finished_at datetime,
|
||||
replay numeric NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_events_deliveries
|
||||
FOREIGN KEY (event_id) REFERENCES events (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_deliveries_deleted_at ON deliveries (deleted_at);
|
||||
CREATE INDEX idx_deliveries_event_id ON deliveries (event_id, deleted_at);
|
||||
CREATE INDEX idx_deliveries_status
|
||||
ON deliveries (status, deleted_at, finished_at, target_id);
|
||||
|
||||
CREATE TABLE delivery_results (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
delivery_id uuid NOT NULL,
|
||||
attempt_num integer NOT NULL,
|
||||
success numeric,
|
||||
status_code integer,
|
||||
response_body text,
|
||||
error text,
|
||||
duration integer,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_deliveries_delivery_results
|
||||
FOREIGN KEY (delivery_id) REFERENCES deliveries (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_delivery_results_deleted_at
|
||||
ON delivery_results (deleted_at);
|
||||
CREATE INDEX idx_delivery_results_delivery_id
|
||||
ON delivery_results (delivery_id, deleted_at);
|
||||
|
||||
CREATE TABLE event_totals (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
events integer NOT NULL,
|
||||
events_removed integer NOT NULL,
|
||||
last_event_at datetime
|
||||
);
|
||||
|
||||
-- The one row of event totals, all zero.
|
||||
INSERT INTO event_totals (events, events_removed) VALUES (0, 0);
|
||||
|
||||
CREATE TABLE target_totals (
|
||||
target_id uuid,
|
||||
deliveries integer NOT NULL,
|
||||
delivered integer NOT NULL,
|
||||
failed integer NOT NULL,
|
||||
deliveries_removed integer NOT NULL,
|
||||
failed_removed integer NOT NULL,
|
||||
PRIMARY KEY (target_id)
|
||||
);
|
||||
|
||||
CREATE TABLE entrypoint_totals (
|
||||
entrypoint_id uuid,
|
||||
last_event_at datetime NOT NULL,
|
||||
PRIMARY KEY (entrypoint_id)
|
||||
);
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Migration 000: the table recording which migrations this file has
|
||||
-- had. Applied before the others; it records itself.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Migration 001: the main database's schema. The models in
|
||||
-- internal/database must agree with it.
|
||||
|
||||
CREATE TABLE settings (
|
||||
"key" text,
|
||||
value text NOT NULL,
|
||||
PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
CREATE TABLE users (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
username text NOT NULL,
|
||||
password text NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT chk_users_username
|
||||
CHECK (length(CAST(username AS BLOB)) <= 1024)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
|
||||
CREATE UNIQUE INDEX idx_users_username ON users (username);
|
||||
|
||||
CREATE TABLE api_keys (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
user_id uuid NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
description text,
|
||||
last_used_at datetime,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_users_api_keys FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_api_keys_deleted_at ON api_keys (deleted_at);
|
||||
CREATE UNIQUE INDEX idx_api_keys_key ON api_keys ("key");
|
||||
|
||||
CREATE TABLE webhooks (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
user_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
retention_days integer,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_webhooks_deleted_at ON webhooks (deleted_at);
|
||||
|
||||
CREATE TABLE entrypoints (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
webhook_id uuid NOT NULL,
|
||||
path text NOT NULL,
|
||||
description text,
|
||||
active numeric DEFAULT true,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_webhooks_entrypoints
|
||||
FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_entrypoints_deleted_at ON entrypoints (deleted_at);
|
||||
CREATE UNIQUE INDEX idx_entrypoints_path ON entrypoints (path);
|
||||
|
||||
CREATE TABLE targets (
|
||||
id uuid,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
deleted_at datetime,
|
||||
webhook_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
type text NOT NULL,
|
||||
active numeric DEFAULT true,
|
||||
url text,
|
||||
headers text,
|
||||
timeout_seconds integer,
|
||||
forward_query numeric,
|
||||
archive_expiry integer,
|
||||
archive_rotation text,
|
||||
max_attempts integer NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT fk_webhooks_targets
|
||||
FOREIGN KEY (webhook_id) REFERENCES webhooks (id),
|
||||
CONSTRAINT chk_targets_max_attempts CHECK (max_attempts >= 1)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_targets_deleted_at ON targets (deleted_at);
|
||||
@@ -0,0 +1,135 @@
|
||||
// Package migrations holds the schema of each kind of SQLite file
|
||||
// webhooker writes, as numbered SQL files embedded in the binary, and
|
||||
// applies it when a file is opened.
|
||||
//
|
||||
// Each kind of file has its own directory. In each, 000_migration.sql
|
||||
// creates only the schema_migrations table, and 001_schema.sql holds
|
||||
// the whole schema. Before 1.0 a schema change is an edit to
|
||||
// 001_schema.sql, never a new file.
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// The directories, one for each kind of database file.
|
||||
const (
|
||||
// Main is the main database, webhooker.db.
|
||||
Main = "main"
|
||||
|
||||
// Events is a webhook's event database, events-{uuid}.db.
|
||||
Events = "events"
|
||||
|
||||
// Archive is a database target's archive file.
|
||||
Archive = "archive"
|
||||
)
|
||||
|
||||
//go:embed main/*.sql events/*.sql archive/*.sql
|
||||
var files embed.FS
|
||||
|
||||
// Apply runs each SQL file in dir, which is Main, Events or Archive,
|
||||
// that the database has not had yet, in the order of the number its
|
||||
// name starts with, and records that number in schema_migrations.
|
||||
// 000_migration.sql creates that table and records itself, so it runs
|
||||
// first, and only when the table is missing.
|
||||
//
|
||||
// It all runs in one transaction, so a failure leaves the file as it
|
||||
// was.
|
||||
func Apply(db *gorm.DB, dir string) error {
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
var tables int
|
||||
|
||||
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
|
||||
err := tx.Raw(
|
||||
"SELECT count(*) FROM sqlite_master "+
|
||||
"WHERE type = 'table' AND name = ?",
|
||||
"schema_migrations",
|
||||
).Row().Scan(&tables)
|
||||
if err != nil {
|
||||
return fmt.Errorf("looking for schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
if tables == 0 {
|
||||
err = run(tx, path.Join(dir, "000_migration.sql"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// fs.Glob lists the files in name order, which is number order.
|
||||
names, err := fs.Glob(files, path.Join(dir, "*.sql"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing migrations: %w", err)
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
err = applyOnce(tx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// applyOnce runs the migration file name and records its number,
|
||||
// unless schema_migrations already has that number.
|
||||
func applyOnce(tx *gorm.DB, name string) error {
|
||||
number, _, _ := strings.Cut(path.Base(name), "_")
|
||||
|
||||
version, err := strconv.Atoi(number)
|
||||
if err != nil {
|
||||
return fmt.Errorf("numbering migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
var applied int
|
||||
|
||||
err = tx.Raw(
|
||||
"SELECT count(*) FROM schema_migrations WHERE version = ?",
|
||||
version,
|
||||
).Row().Scan(&applied)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
if applied > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = run(tx, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Exec(
|
||||
"INSERT INTO schema_migrations (version) VALUES (?)", version,
|
||||
).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// run executes every statement in the migration file name.
|
||||
func run(tx *gorm.DB, name string) error {
|
||||
script, err := files.ReadFile(name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
err = tx.Exec(string(script)).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("applying migration %s: %w", name, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package migrations_test
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite"
|
||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
// TestApplyRunsEachFileOnce applies each kind of database file's
|
||||
// migrations to a new file twice. The first time records every file.
|
||||
// The second finds them all recorded and runs none, which is the only
|
||||
// way it can succeed: 001_schema.sql creates tables that then exist.
|
||||
func TestApplyRunsEachFileOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, dir := range []string{
|
||||
migrations.Main, migrations.Events, migrations.Archive,
|
||||
} {
|
||||
t.Run(dir, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite", filepath.Join(t.TempDir(), "test.db"),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
db, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{
|
||||
Logger: gormlog.New(slog.New(slog.DiscardHandler)),
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, migrations.Apply(db, dir))
|
||||
require.NoError(t, migrations.Apply(db, dir))
|
||||
|
||||
var versions string
|
||||
|
||||
require.NoError(t, sqlDB.QueryRowContext(t.Context(),
|
||||
"SELECT group_concat(version) FROM schema_migrations",
|
||||
).Scan(&versions))
|
||||
assert.Equal(t, "0,1", versions)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -46,12 +46,7 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &delivery.HTTPTargetConfig{
|
||||
URL: target,
|
||||
Timeout: 5,
|
||||
}
|
||||
|
||||
client := engine.ExportClientForRequest(cfg, nil)
|
||||
client := engine.ExportClientForRequest(5, nil)
|
||||
|
||||
require.NotSame(t, engine.ExportClient(), client,
|
||||
"a per-target timeout must yield a "+
|
||||
@@ -101,11 +96,7 @@ func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
|
||||
|
||||
engine := newSSRFTestEngine()
|
||||
|
||||
cfg := &delivery.HTTPTargetConfig{
|
||||
URL: "https://example.com/hook",
|
||||
}
|
||||
|
||||
client := engine.ExportClientForRequest(cfg, nil)
|
||||
client := engine.ExportClientForRequest(0, nil)
|
||||
|
||||
assert.Same(t, engine.ExportClient(), client,
|
||||
"without a per-target timeout the shared client "+
|
||||
@@ -123,15 +114,8 @@ func TestClientForRequest_HeadersKeepSSRFGuard(t *testing.T) {
|
||||
|
||||
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"},
|
||||
0, []string{probeHeaderName},
|
||||
)
|
||||
|
||||
require.NotNil(t, client.CheckRedirect,
|
||||
|
||||
@@ -32,8 +32,9 @@ func targetTotals(
|
||||
|
||||
// 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.
|
||||
// finished, set only when it becomes delivered or failed and stored in
|
||||
// the same form as created_at, and one more on its target's delivered
|
||||
// or failed total.
|
||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -75,6 +76,16 @@ func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
||||
if tt.finished {
|
||||
require.NotNil(t, stored.FinishedAt)
|
||||
assert.False(t, stored.FinishedAt.Before(before))
|
||||
|
||||
var createdAt, finishedAt string
|
||||
|
||||
require.NoError(t, db.Raw(
|
||||
`SELECT CAST(created_at AS TEXT),
|
||||
CAST(finished_at AS TEXT)
|
||||
FROM deliveries WHERE id = ?`, d.ID,
|
||||
).Row().Scan(&createdAt, &finishedAt))
|
||||
assertStoredTime(t, createdAt)
|
||||
assertStoredTime(t, finishedAt)
|
||||
} else {
|
||||
assert.Nil(t, stored.FinishedAt)
|
||||
}
|
||||
|
||||
+273
-522
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,12 @@ package delivery_test
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
@@ -19,12 +17,11 @@ import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
// iSetup holds common integration test dependencies.
|
||||
@@ -45,12 +42,12 @@ func newISetup(t *testing.T) iSetup {
|
||||
wDB := iSeedWebhookDB(t, dbMgr, wID)
|
||||
|
||||
return iSetup{
|
||||
MainDB: mainDB,
|
||||
MainDB: mainDB.DB(),
|
||||
DBMgr: dbMgr,
|
||||
WebhookID: wID,
|
||||
WebhookDB: wDB,
|
||||
Engine: delivery.NewTestEngineWithDB(
|
||||
database.NewTestDatabase(mainDB),
|
||||
mainDB,
|
||||
dbMgr,
|
||||
slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
@@ -64,35 +61,16 @@ func newISetup(t *testing.T) iSetup {
|
||||
}
|
||||
}
|
||||
|
||||
func iMainDB(t *testing.T) *gorm.DB {
|
||||
// iMainDB opens a main database through database.Open, the way the
|
||||
// service opens it, so these tests cannot pass against journal and
|
||||
// locking settings production does not use.
|
||||
func iMainDB(t *testing.T) *database.Database {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(
|
||||
t.TempDir(), "main-test.db",
|
||||
)
|
||||
|
||||
// Opened the way the service opens the main database, so these
|
||||
// tests cannot pass against journal and locking settings
|
||||
// production does not use.
|
||||
sqlDB, err := database.OpenSQLite(
|
||||
dbPath, database.SQLiteModeCreate,
|
||||
)
|
||||
db, err := database.Open(t.TempDir(), slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
db, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&database.Webhook{},
|
||||
&database.Target{},
|
||||
&database.User{},
|
||||
&database.Setting{},
|
||||
))
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return db
|
||||
}
|
||||
@@ -102,7 +80,7 @@ func iDBManager(
|
||||
) *database.WebhookDBManager {
|
||||
t.Helper()
|
||||
|
||||
return database.NewTestWebhookDBManager(t.TempDir())
|
||||
return databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||
}
|
||||
|
||||
func iSeedWebhookDB(
|
||||
@@ -118,17 +96,6 @@ func iSeedWebhookDB(
|
||||
return db
|
||||
}
|
||||
|
||||
func iHTTPConfig(url string) string {
|
||||
cfg := delivery.HTTPTargetConfig{URL: url}
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
panic("failed to marshal HTTPTargetConfig")
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func iEngine(
|
||||
t *testing.T, workers int,
|
||||
) *delivery.Engine {
|
||||
@@ -190,24 +157,24 @@ func iSeedDelivery(
|
||||
func iTask(
|
||||
d database.Delivery,
|
||||
event database.Event,
|
||||
webhookID, targetID, name, config string,
|
||||
maxRetries, attemptNum int,
|
||||
webhookID, targetID, name, targetURL string,
|
||||
maxAttempts, attemptNum int,
|
||||
body *string,
|
||||
) delivery.Task {
|
||||
return delivery.Task{
|
||||
DeliveryID: d.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: webhookID,
|
||||
TargetID: targetID,
|
||||
TargetName: name,
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetConfig: config,
|
||||
MaxRetries: maxRetries,
|
||||
Method: event.Method,
|
||||
Headers: event.Headers,
|
||||
ContentType: event.ContentType,
|
||||
Body: body,
|
||||
AttemptNum: attemptNum,
|
||||
DeliveryID: d.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: webhookID,
|
||||
TargetID: targetID,
|
||||
TargetName: name,
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetURL: targetURL,
|
||||
MaxAttempts: maxAttempts,
|
||||
Method: event.Method,
|
||||
Headers: event.Headers,
|
||||
ContentType: event.ContentType,
|
||||
Body: body,
|
||||
AttemptNum: attemptNum,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,10 +227,9 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
|
||||
)
|
||||
|
||||
bodyStr := event.Body
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"test-target", cfg, 0, 1, &bodyStr,
|
||||
"test-target", ts.URL, 1, 1, &bodyStr,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(
|
||||
@@ -311,10 +277,9 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"test-large", cfg, 0, 1, nil,
|
||||
"test-large", ts.URL, 1, 1, nil,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(
|
||||
@@ -334,16 +299,16 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
|
||||
s := newISetup(t)
|
||||
|
||||
task := delivery.Task{
|
||||
DeliveryID: uuid.New().String(),
|
||||
EventID: uuid.New().String(),
|
||||
WebhookID: uuid.New().String(),
|
||||
TargetID: uuid.New().String(),
|
||||
TargetName: "test",
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetConfig: iHTTPConfig("http://localhost:9999"),
|
||||
MaxRetries: 0,
|
||||
Body: nil,
|
||||
AttemptNum: 1,
|
||||
DeliveryID: uuid.New().String(),
|
||||
EventID: uuid.New().String(),
|
||||
WebhookID: uuid.New().String(),
|
||||
TargetID: uuid.New().String(),
|
||||
TargetName: "test",
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetURL: "http://localhost:9999",
|
||||
MaxAttempts: 1,
|
||||
Body: nil,
|
||||
AttemptNum: 1,
|
||||
}
|
||||
|
||||
s.Engine.ExportProcessNewTask(
|
||||
@@ -384,7 +349,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
||||
)
|
||||
|
||||
bodyStr := event.Body
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
|
||||
// The target row exists because the engine confirms a scheduled
|
||||
// retry's target has not been deleted before it runs it. A retry
|
||||
@@ -393,12 +357,12 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
|
||||
// task. See https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "retry-target",
|
||||
database.TargetTypeHTTP, cfg, 5,
|
||||
database.TargetTypeHTTP, ts.URL, 5,
|
||||
)
|
||||
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"retry-target", cfg, 5, 2, &bodyStr,
|
||||
"retry-target", ts.URL, 5, 2, &bodyStr,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(
|
||||
@@ -431,10 +395,9 @@ func TestProcessRetryTask_SkipsNonRetryingDelivery(
|
||||
)
|
||||
|
||||
bodyStr := event.Body
|
||||
cfg := iHTTPConfig("http://localhost:1")
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"skip-target", cfg, 5, 2, &bodyStr,
|
||||
"skip-target", "http://localhost:1", 5, 2, &bodyStr,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(
|
||||
@@ -481,16 +444,14 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
|
||||
database.DeliveryStatusRetrying,
|
||||
)
|
||||
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "retry-large",
|
||||
database.TargetTypeHTTP, cfg, 5,
|
||||
database.TargetTypeHTTP, ts.URL, 5,
|
||||
)
|
||||
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"retry-large", cfg, 5, 2, nil,
|
||||
"retry-large", ts.URL, 5, 2, nil,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(
|
||||
@@ -527,11 +488,11 @@ func TestWorkerLifecycle_StartStop(t *testing.T) {
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"lifecycle-test", "",
|
||||
0, 1, &bodyStr,
|
||||
1, 1, &bodyStr,
|
||||
)
|
||||
task.TargetType = database.TargetTypeLog
|
||||
|
||||
s.Engine.Notify([]delivery.Task{task})
|
||||
s.Engine.ExportNotify([]delivery.Task{task})
|
||||
|
||||
iWaitForDelivered(t, s.WebhookDB, d.ID)
|
||||
|
||||
@@ -591,16 +552,15 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
|
||||
s.Engine.ExportStart()
|
||||
|
||||
bodyStr := event.Body
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "retry-chan-test",
|
||||
database.TargetTypeHTTP, cfg, 5,
|
||||
database.TargetTypeHTTP, ts.URL, 5,
|
||||
)
|
||||
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"retry-chan-test", cfg, 5, 2, &bodyStr,
|
||||
"retry-chan-test", ts.URL, 5, 2, &bodyStr,
|
||||
)
|
||||
|
||||
s.Engine.ExportRetryCh() <- task
|
||||
@@ -666,16 +626,21 @@ func TestRecoverPendingDeliveries(t *testing.T) {
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "recovery-target",
|
||||
database.TargetTypeLog, "", 0,
|
||||
database.TargetTypeLog, "", 1,
|
||||
)
|
||||
|
||||
iSeedPendingDeliveries(
|
||||
t, s.WebhookDB, s.WebhookID, targetID, 3,
|
||||
)
|
||||
|
||||
// A recovered delivery still carries its event's query string.
|
||||
require.NoError(t, s.WebhookDB.Model(&database.Event{}).
|
||||
Where("webhook_id = ?", s.WebhookID).
|
||||
Update("raw_query", eventQuery).Error)
|
||||
|
||||
s.Engine.ExportRecoverPendingDeliveries(
|
||||
context.Background(), s.WebhookDB,
|
||||
s.WebhookID,
|
||||
s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
for i := range 3 {
|
||||
@@ -687,6 +652,8 @@ func TestRecoverPendingDeliveries(t *testing.T) {
|
||||
database.TargetTypeLog,
|
||||
task.TargetType,
|
||||
)
|
||||
|
||||
assert.Equal(t, eventQuery, task.RawQuery)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("expected task %d", i)
|
||||
}
|
||||
@@ -699,18 +666,18 @@ func iCreateTarget(
|
||||
mainDB *gorm.DB,
|
||||
targetID, webhookID, name string,
|
||||
targetType database.TargetType,
|
||||
config string,
|
||||
maxRetries int,
|
||||
targetURL string,
|
||||
maxAttempts int,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: name,
|
||||
Type: targetType,
|
||||
Active: true,
|
||||
Config: config,
|
||||
MaxRetries: maxRetries,
|
||||
WebhookID: webhookID,
|
||||
Name: name,
|
||||
Type: targetType,
|
||||
Active: true,
|
||||
URL: targetURL,
|
||||
MaxAttempts: maxAttempts,
|
||||
}
|
||||
target.ID = targetID
|
||||
|
||||
@@ -750,7 +717,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "retry-recovery",
|
||||
database.TargetTypeHTTP,
|
||||
iHTTPConfig("http://example.com/hook"), 5,
|
||||
"http://example.com/hook", 5,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -770,7 +737,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
|
||||
)
|
||||
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID,
|
||||
context.Background(), s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -810,7 +777,7 @@ func iSeedRetryingWithType(
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "mutated-target", targetType,
|
||||
iHTTPConfig("http://example.com/hook"), 5,
|
||||
"http://example.com/hook", 5,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -894,7 +861,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries(
|
||||
)
|
||||
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID,
|
||||
context.Background(), s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
iAssertTerminallyFailed(
|
||||
@@ -902,7 +869,7 @@ func TestRecoverSingleRetry_TypeNoLongerRetries(
|
||||
)
|
||||
}
|
||||
|
||||
func TestSweepSingleRetry_TypeNoLongerRetries(
|
||||
func TestRecoverSingleRetry_OnTick_TypeNoLongerRetries(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -917,8 +884,8 @@ func TestSweepSingleRetry_TypeNoLongerRetries(
|
||||
t, s, database.TargetTypeDatabase,
|
||||
)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
iAssertTerminallyFailed(
|
||||
@@ -948,14 +915,14 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
|
||||
|
||||
targetID := uuid.New().String()
|
||||
|
||||
// A Slack incoming-webhook URL: the target config IS the
|
||||
// A Slack incoming-webhook URL: the target's URL 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,
|
||||
database.TargetTypeLog, hookURL, 5,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -969,37 +936,20 @@ func TestFailUnretryableRetry_WritesNoTargetRow(
|
||||
|
||||
iSeedFailedResult(t, s.WebhookDB, d.ID)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
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,
|
||||
// The per-webhook database has no targets table, so writing a
|
||||
// target row there would have failed the status write above.
|
||||
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"),
|
||||
"orphaned-retry terminal failure wrote a target row "+
|
||||
"into the per-webhook event database",
|
||||
)
|
||||
|
||||
var configs []string
|
||||
|
||||
require.NoError(t, s.WebhookDB.
|
||||
Table("targets").
|
||||
Pluck("config", &configs).Error)
|
||||
|
||||
assert.NotContains(
|
||||
t, strings.Join(configs, " "), hookURL,
|
||||
)
|
||||
}
|
||||
|
||||
func TestRecoverSingleRetry_UnknownTargetType(
|
||||
@@ -1018,13 +968,13 @@ func TestRecoverSingleRetry_UnknownTargetType(
|
||||
deliveryID := iSeedRetryingWithType(t, s, unknown)
|
||||
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID,
|
||||
context.Background(), s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
iAssertTerminallyFailed(t, s, deliveryID, unknown)
|
||||
}
|
||||
|
||||
func TestSweepSingleRetry_UnknownTargetType(
|
||||
func TestRecoverSingleRetry_OnTick_UnknownTargetType(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -1039,8 +989,8 @@ func TestSweepSingleRetry_UnknownTargetType(
|
||||
|
||||
deliveryID := iSeedRetryingWithType(t, s, unknown)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
iAssertTerminallyFailed(t, s, deliveryID, unknown)
|
||||
@@ -1082,19 +1032,19 @@ func iCreateWebhook(
|
||||
require.NoError(t, mainDB.Create(&webhook).Error)
|
||||
}
|
||||
|
||||
// --- recoverInFlight Tests ---
|
||||
// --- recoverDeliveries Tests ---
|
||||
|
||||
func TestRecoverInFlight_NoWebhooks(t *testing.T) {
|
||||
func TestRecoverDeliveries_NoWebhooks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
s.Engine.ExportRecoverInFlight(
|
||||
context.Background(),
|
||||
s.Engine.ExportRecoverDeliveries(
|
||||
context.Background(), atStart,
|
||||
)
|
||||
}
|
||||
|
||||
func TestRecoverInFlight_WithPendingDeliveries(
|
||||
func TestRecoverDeliveries_WithPendingDeliveries(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
@@ -1108,7 +1058,7 @@ func TestRecoverInFlight_WithPendingDeliveries(
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "recover-target",
|
||||
database.TargetTypeLog, "", 0,
|
||||
database.TargetTypeLog, "", 1,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -1121,8 +1071,8 @@ func TestRecoverInFlight_WithPendingDeliveries(
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
s.Engine.ExportRecoverInFlight(
|
||||
context.Background(),
|
||||
s.Engine.ExportRecoverDeliveries(
|
||||
context.Background(), atStart,
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -1133,36 +1083,36 @@ func TestRecoverInFlight_WithPendingDeliveries(
|
||||
database.TargetTypeLog, task.TargetType,
|
||||
)
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("expected task from recoverInFlight")
|
||||
t.Fatal("expected task from recovery at start")
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
// TestRecoverDeliveries_ReportsAMissingWebhookDatabase covers a webhook
|
||||
// whose database file is gone, after a partial restore say. Recovery at
|
||||
// start opens every webhook's database, so the empty one made in its
|
||||
// place is reported at start, naming the file
|
||||
// (https://git.eeqj.de/sneak/webhooker/issues/290).
|
||||
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
|
||||
func TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mainDB := iMainDB(t)
|
||||
webhookID := uuid.New().String()
|
||||
iCreateWebhook(t, mainDB, webhookID, "lost-database")
|
||||
iCreateWebhook(t, mainDB.DB(), webhookID, "lost-database")
|
||||
|
||||
var logs bytes.Buffer
|
||||
|
||||
dbMgr := database.NewTestWebhookDBManagerWithLogger(
|
||||
t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
|
||||
dbMgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||
t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
|
||||
)
|
||||
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
||||
|
||||
engine := delivery.NewTestEngineWithDB(
|
||||
database.NewTestDatabase(mainDB), dbMgr,
|
||||
mainDB, dbMgr,
|
||||
slog.New(slog.DiscardHandler),
|
||||
&http.Client{Timeout: 5 * time.Second}, 1,
|
||||
)
|
||||
|
||||
engine.ExportRecoverInFlight(context.Background())
|
||||
engine.ExportRecoverDeliveries(context.Background(), atStart)
|
||||
|
||||
assert.Contains(
|
||||
t, logs.String(),
|
||||
@@ -1171,24 +1121,25 @@ func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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(
|
||||
// TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead covers a
|
||||
// webhook deleted from the web interface while the recovery routine
|
||||
// runs. Its database file is gone, and recovery must not create it
|
||||
// again.
|
||||
func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
mainDB := iMainDB(t)
|
||||
webhookID := uuid.New().String()
|
||||
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
|
||||
iCreateWebhook(t, mainDB.DB(), webhookID, "deleted-during-recovery")
|
||||
|
||||
// The first query to return is recovery's read of the list of
|
||||
// webhooks. Deleting the webhook right after it puts the delete
|
||||
// between that read and the opening of the webhook's database.
|
||||
deleted := false
|
||||
|
||||
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
|
||||
require.NoError(t, mainDB.DB().Callback().Query().After("gorm:query").
|
||||
Register("delete-after-list", func(*gorm.DB) {
|
||||
if deleted {
|
||||
return
|
||||
@@ -1196,27 +1147,27 @@ func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
|
||||
|
||||
deleted = true
|
||||
|
||||
require.NoError(t, mainDB.Delete(
|
||||
require.NoError(t, mainDB.DB().Delete(
|
||||
&database.Webhook{}, "id = ?", webhookID,
|
||||
).Error)
|
||||
}))
|
||||
|
||||
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
|
||||
dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
||||
|
||||
engine := delivery.NewTestEngineWithDB(
|
||||
database.NewTestDatabase(mainDB), dbMgr,
|
||||
mainDB, dbMgr,
|
||||
slog.New(slog.DiscardHandler),
|
||||
&http.Client{Timeout: 5 * time.Second}, 1,
|
||||
)
|
||||
|
||||
engine.ExportRecoverInFlight(context.Background())
|
||||
engine.ExportRecoverDeliveries(context.Background(), atStart)
|
||||
|
||||
require.True(t, deleted)
|
||||
assert.False(t, dbMgr.DBExists(webhookID))
|
||||
}
|
||||
|
||||
// --- HTTP Config with custom headers ---
|
||||
// --- HTTP target with custom headers ---
|
||||
|
||||
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
@@ -1238,16 +1189,6 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||
)
|
||||
defer ts.Close()
|
||||
|
||||
cfg := delivery.HTTPTargetConfig{
|
||||
URL: ts.URL,
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer secret-token",
|
||||
},
|
||||
}
|
||||
|
||||
cfgJSON, err := json.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := iSeedEvent(
|
||||
t, s.WebhookDB, s.WebhookID,
|
||||
`{"auth":"test"}`,
|
||||
@@ -1262,9 +1203,12 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||
bodyStr := event.Body
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"auth-target", string(cfgJSON),
|
||||
0, 1, &bodyStr,
|
||||
"auth-target", ts.URL,
|
||||
1, 1, &bodyStr,
|
||||
)
|
||||
task.TargetHeaders = map[string]string{
|
||||
headerAuthorization: "Bearer secret-token",
|
||||
}
|
||||
|
||||
s.Engine.ExportProcessNewTask(
|
||||
context.TODO(), &task,
|
||||
@@ -1277,11 +1221,14 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||
|
||||
// --- HTTP delivery with custom timeout ---
|
||||
|
||||
// TestDeliverHTTP_TargetTimeout proves a task's TimeoutSeconds reaches
|
||||
// the delivery through the engine. The destination answers after two
|
||||
// seconds, inside the engine's own five-second client timeout, so only
|
||||
// the task's one second can fail the delivery.
|
||||
func TestDeliverHTTP_TargetTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := iEngine(t, 1)
|
||||
s := newISetup(t)
|
||||
|
||||
ts := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
@@ -1293,31 +1240,30 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
|
||||
)
|
||||
defer ts.Close()
|
||||
|
||||
cfg := delivery.HTTPTargetConfig{
|
||||
URL: ts.URL,
|
||||
Timeout: 1,
|
||||
}
|
||||
event := iSeedEvent(
|
||||
t, s.WebhookDB, s.WebhookID, `{"timeout":"test"}`,
|
||||
)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
cfgJSON, err := json.Marshal(cfg)
|
||||
require.NoError(t, err)
|
||||
|
||||
event, del := iSeedEventAndDelivery(
|
||||
t, db, `{"timeout":"test"}`,
|
||||
string(cfgJSON),
|
||||
d := iSeedDelivery(
|
||||
t, s.WebhookDB, event.ID, targetID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
task, d := iHTTPTaskAndDelivery(
|
||||
event, del, "timeout-target",
|
||||
string(cfgJSON), 0, 1,
|
||||
bodyStr := event.Body
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"timeout-target", ts.URL, 1, 1, &bodyStr,
|
||||
)
|
||||
task.TimeoutSeconds = 1
|
||||
|
||||
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
|
||||
iAssertStatus(t, db, del.ID,
|
||||
iAssertStatus(t, s.WebhookDB, d.ID,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
|
||||
iAssertResultFailed(t, db, del.ID)
|
||||
iAssertResultFailed(t, s.WebhookDB, d.ID)
|
||||
}
|
||||
|
||||
// TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size
|
||||
@@ -1355,8 +1301,6 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
|
||||
))
|
||||
defer ts.Close()
|
||||
|
||||
cfgJSON := iHTTPConfig(ts.URL)
|
||||
|
||||
event := iSeedEvent(
|
||||
t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`,
|
||||
)
|
||||
@@ -1370,7 +1314,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
|
||||
bodyStr := event.Body
|
||||
task := iTask(
|
||||
del, event, s.WebhookID, targetID,
|
||||
"cut-target", cfgJSON, 0, 1, &bodyStr,
|
||||
"cut-target", ts.URL, 1, 1, &bodyStr,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
@@ -1402,7 +1346,7 @@ func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
|
||||
func iSeedEventAndDelivery(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
body, _ string,
|
||||
body string,
|
||||
) (database.Event, database.Delivery) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1433,19 +1377,19 @@ func iSeedEventAndDelivery(
|
||||
func iHTTPTaskAndDelivery(
|
||||
event database.Event,
|
||||
del database.Delivery,
|
||||
name, config string,
|
||||
maxRetries, attemptNum int,
|
||||
name, targetURL string,
|
||||
maxAttempts, attemptNum int,
|
||||
) (*delivery.Task, *database.Delivery) {
|
||||
task := &delivery.Task{
|
||||
DeliveryID: del.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: event.WebhookID,
|
||||
TargetID: del.TargetID,
|
||||
TargetName: name,
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetConfig: config,
|
||||
MaxRetries: maxRetries,
|
||||
AttemptNum: attemptNum,
|
||||
DeliveryID: del.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: event.WebhookID,
|
||||
TargetID: del.TargetID,
|
||||
TargetName: name,
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetURL: targetURL,
|
||||
MaxAttempts: maxAttempts,
|
||||
AttemptNum: attemptNum,
|
||||
}
|
||||
|
||||
d := &database.Delivery{
|
||||
@@ -1454,9 +1398,10 @@ func iHTTPTaskAndDelivery(
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: name,
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: config,
|
||||
Name: name,
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: targetURL,
|
||||
MaxAttempts: maxAttempts,
|
||||
},
|
||||
}
|
||||
d.ID = del.ID
|
||||
@@ -1484,56 +1429,30 @@ func iAssertResultFailed(
|
||||
assert.NotEmpty(t, result.Error)
|
||||
}
|
||||
|
||||
// --- HTTP request with invalid config ---
|
||||
// --- HTTP request with no URL ---
|
||||
|
||||
func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
||||
// TestDeliverHTTP_NoURL proves an http target without a URL, which
|
||||
// the forms never store, fails its delivery and records why.
|
||||
func TestDeliverHTTP_NoURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
e := iEngine(t, 1)
|
||||
|
||||
event, del := iSeedEventAndDelivery(
|
||||
t, db, `{"config":"invalid"}`, "",
|
||||
)
|
||||
event, del := iSeedEventAndDelivery(t, db, `{"url":"none"}`)
|
||||
|
||||
task, d := iHTTPTaskAndDelivery(
|
||||
event, del, "bad-config", `not-json`, 0, 1,
|
||||
)
|
||||
task, d := iHTTPTaskAndDelivery(event, del, "no-url", "", 1, 1)
|
||||
|
||||
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
||||
|
||||
iAssertStatus(t, db, del.ID,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
|
||||
iAssertResultFailed(t, db, del.ID)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
@@ -1548,7 +1467,7 @@ func TestNotify_MultipleTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
e.Notify(tasks)
|
||||
e.ExportNotify(tasks)
|
||||
|
||||
for i := range 5 {
|
||||
select {
|
||||
|
||||
@@ -2,11 +2,16 @@ package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -24,7 +29,7 @@ const (
|
||||
// OnStart before the caller may enqueue work. A worker pool
|
||||
// wrongly rooted in the already-done hook context has nothing
|
||||
// but ctx.Done() ready in its select, so it is deterministically
|
||||
// gone by the end of this window. Without the wait, Notify would
|
||||
// gone by the end of this window. Without the wait, notify would
|
||||
// race the pool's very first select, in which a ready ctx.Done()
|
||||
// and a ready deliveryCh are chosen between at random and a
|
||||
// doomed pool still delivers.
|
||||
@@ -94,7 +99,7 @@ func requireStopHookExpires(
|
||||
// the caller can drive OnStop too.
|
||||
//
|
||||
// Callers must not seed pending or retrying deliveries before
|
||||
// calling this: restart recovery enqueues those during startup,
|
||||
// calling this: the recovery routine's run at start enqueues those,
|
||||
// which would put work in the queue while the pool is still
|
||||
// racing its first select.
|
||||
func startEngineViaHook(
|
||||
@@ -144,7 +149,7 @@ func seedLogTask(
|
||||
bodyStr := event.Body
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"hook-context-test", "", 0, 1, &bodyStr,
|
||||
"hook-context-test", "", 1, 1, &bodyStr,
|
||||
)
|
||||
task.TargetType = database.TargetTypeLog
|
||||
|
||||
@@ -173,11 +178,11 @@ func TestEngine_WorkersOutliveStartHookContext(t *testing.T) {
|
||||
_ = lc.hooks[0].OnStop(context.Background())
|
||||
})
|
||||
|
||||
// Seeded only after the pool has settled, so restart recovery
|
||||
// cannot enqueue it during startup.
|
||||
// Seeded only after the pool has settled, so the recovery
|
||||
// routine's run at start cannot enqueue it.
|
||||
deliveryID, task := seedLogTask(t, s)
|
||||
|
||||
s.Engine.Notify([]delivery.Task{task})
|
||||
s.Engine.ExportNotify([]delivery.Task{task})
|
||||
|
||||
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
||||
}
|
||||
@@ -197,7 +202,7 @@ func TestEngine_StopHookStopsWorkers(t *testing.T) {
|
||||
// Let the pool prove it is running before stopping it, so a
|
||||
// fast OnStop cannot pass by stopping something already dead.
|
||||
firstID, firstTask := seedLogTask(t, s)
|
||||
s.Engine.Notify([]delivery.Task{firstTask})
|
||||
s.Engine.ExportNotify([]delivery.Task{firstTask})
|
||||
iWaitForDelivered(t, s.WebhookDB, firstID)
|
||||
|
||||
var stopErr error
|
||||
@@ -227,7 +232,7 @@ func TestEngine_StopHookStopsWorkers(t *testing.T) {
|
||||
// With every worker gone, a freshly notified task must sit
|
||||
// untouched in the queue rather than being delivered.
|
||||
secondID, secondTask := seedLogTask(t, s)
|
||||
s.Engine.Notify([]delivery.Task{secondTask})
|
||||
s.Engine.ExportNotify([]delivery.Task{secondTask})
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
@@ -272,19 +277,168 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
|
||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||
}
|
||||
|
||||
// seedHeldDelivery gives the setup's webhook an http target with
|
||||
// maxAttempts, pointed at a receiver that keeps every request open until
|
||||
// the client gives up on it, and seeds one delivery to that target at
|
||||
// status. It returns the task that sends attempt attemptNum of the
|
||||
// delivery, and a channel that is closed when a request reaches the
|
||||
// receiver.
|
||||
func seedHeldDelivery(
|
||||
t *testing.T,
|
||||
s iSetup,
|
||||
maxAttempts int,
|
||||
status database.DeliveryStatus,
|
||||
attemptNum int,
|
||||
) (delivery.Task, <-chan struct{}) {
|
||||
t.Helper()
|
||||
|
||||
arrived := make(chan struct{})
|
||||
|
||||
var once sync.Once
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(
|
||||
func(_ http.ResponseWriter, r *http.Request) {
|
||||
// The server notices the client hanging up only once the
|
||||
// body has been read; until then r.Context() never ends.
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
|
||||
once.Do(func() { close(arrived) })
|
||||
|
||||
<-r.Context().Done()
|
||||
},
|
||||
))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
targetID := uuid.New().String()
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "held",
|
||||
database.TargetTypeHTTP, ts.URL, maxAttempts,
|
||||
)
|
||||
|
||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
|
||||
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
|
||||
|
||||
bodyStr := event.Body
|
||||
|
||||
return iTask(
|
||||
d, event, s.WebhookID, targetID, "held", ts.URL,
|
||||
maxAttempts, attemptNum, &bodyStr,
|
||||
), arrived
|
||||
}
|
||||
|
||||
// stopDuringAttempt waits until the receiver holds an attempt open, then
|
||||
// stops the engine through its real stop hook.
|
||||
func stopDuringAttempt(
|
||||
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-arrived:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the delivery attempt never reached the receiver")
|
||||
}
|
||||
|
||||
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
|
||||
}
|
||||
|
||||
// requireRequeued runs the recovery routine as at start on the stopped
|
||||
// engine and requires it to queue task's attempt again on ch.
|
||||
func requireRequeued(
|
||||
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
select {
|
||||
case got := <-ch:
|
||||
assert.Equal(t, task.DeliveryID, got.DeliveryID)
|
||||
assert.Equal(t, task.AttemptNum, got.AttemptNum)
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("recovery at start did not queue the delivery again")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
|
||||
// test for a stop that turned an attempt in flight into a terminal
|
||||
// failure. Stopping the engine cancels the request; for a target with
|
||||
// no retries that was the delivery's only attempt, recorded as failed
|
||||
// with "context canceled", and nothing ever sent it again. The
|
||||
// delivery must instead stay pending, with no result and no count in
|
||||
// the attempt metrics, for the recovery routine to send at the next
|
||||
// start.
|
||||
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
reg := mIsolate(t, s)
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
task, arrived := seedHeldDelivery(
|
||||
t, s, 1, database.DeliveryStatusPending, 1,
|
||||
)
|
||||
|
||||
s.Engine.ExportNotify([]delivery.Task{task})
|
||||
stopDuringAttempt(t, lc, arrived)
|
||||
|
||||
iAssertStatus(
|
||||
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
|
||||
)
|
||||
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
|
||||
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
|
||||
assert.Zero(t, mHTTPDurations(t, reg))
|
||||
|
||||
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
|
||||
}
|
||||
|
||||
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
|
||||
// for a target with retries, on its last attempt: the cancelled attempt
|
||||
// used to exhaust the retries and end the delivery failed. It must stay
|
||||
// retrying with only its earlier attempt recorded, and the recovery
|
||||
// routine at the next start must send that last attempt again.
|
||||
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxAttempts = 2
|
||||
|
||||
s := newISetup(t)
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
task, arrived := seedHeldDelivery(
|
||||
t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts,
|
||||
)
|
||||
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
|
||||
|
||||
s.Engine.ExportScheduleRetry(task, 0)
|
||||
stopDuringAttempt(t, lc, arrived)
|
||||
|
||||
iAssertStatus(
|
||||
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
|
||||
)
|
||||
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
|
||||
|
||||
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
|
||||
}
|
||||
|
||||
// deliverToArchive gives the setup's webhook a database target,
|
||||
// runs one delivery to it through the running engine, and returns
|
||||
// the target's ID and archive file path. The archive writer holds
|
||||
// the file open afterwards.
|
||||
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
|
||||
// the target's archive file path. The archive writer holds the file
|
||||
// open afterwards.
|
||||
func deliverToArchive(t *testing.T, s iSetup) string {
|
||||
t.Helper()
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
|
||||
|
||||
tgt := &database.Target{
|
||||
WebhookID: s.WebhookID,
|
||||
Name: "archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
WebhookID: s.WebhookID,
|
||||
Name: "archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
require.NoError(
|
||||
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
|
||||
@@ -294,11 +448,11 @@ func deliverToArchive(t *testing.T, s iSetup) (string, string) {
|
||||
task.TargetID = tgt.ID
|
||||
task.TargetType = database.TargetTypeDatabase
|
||||
|
||||
s.Engine.Notify([]delivery.Task{task})
|
||||
s.Engine.ExportNotify([]delivery.Task{task})
|
||||
|
||||
iWaitForDelivered(t, s.WebhookDB, deliveryID)
|
||||
|
||||
return tgt.ID, filepath.Join(
|
||||
return filepath.Join(
|
||||
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
|
||||
"archive-hook-archive-"+tgt.ID+".db",
|
||||
)
|
||||
@@ -317,7 +471,7 @@ func TestEngine_StopHookClosesArchives(t *testing.T) {
|
||||
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
_, path := deliverToArchive(t, s)
|
||||
path := deliverToArchive(t, s)
|
||||
require.FileExists(
|
||||
t, path+"-wal",
|
||||
"an open archive should have a -wal for the stop to remove",
|
||||
@@ -351,21 +505,23 @@ func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
|
||||
|
||||
lc := startEngineViaHook(t, s.Engine)
|
||||
|
||||
targetID, _ := deliverToArchive(t, s)
|
||||
path := deliverToArchive(t, s)
|
||||
|
||||
release := make(chan struct{})
|
||||
|
||||
t.Cleanup(func() {
|
||||
close(release)
|
||||
s.Engine.EvictWebhook(s.WebhookID)
|
||||
s.Engine.ExportArchives().EvictWebhook(s.WebhookID)
|
||||
})
|
||||
|
||||
s.Engine.ExportWedgeWorker(release)
|
||||
|
||||
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
|
||||
|
||||
require.True(
|
||||
t, s.Engine.ExportArchiveHandleOpen(targetID),
|
||||
// Closing the last handle on an archive removes its -wal, so a
|
||||
// -wal still there is a writer still open.
|
||||
require.FileExists(
|
||||
t, path+"-wal",
|
||||
"a stop that timed out must not close archive writers",
|
||||
)
|
||||
}
|
||||
|
||||
+202
-265
@@ -10,7 +10,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -21,12 +21,11 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
_ "modernc.org/sqlite"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
)
|
||||
|
||||
@@ -36,35 +35,18 @@ const testContentType = "application/json"
|
||||
func testWebhookDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
dbPath := filepath.Join(
|
||||
t.TempDir(), "events-test.db",
|
||||
// Opened by the manager the service uses, so these tests cannot
|
||||
// pass against a schema, journal and locking settings or write
|
||||
// guard production does not use.
|
||||
mgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||
t, t.TempDir(), slog.New(slog.DiscardHandler),
|
||||
)
|
||||
|
||||
// Opened the way the service opens a per-webhook database, so
|
||||
// these tests cannot pass against journal and locking settings
|
||||
// production does not use.
|
||||
sqlDB, err := database.OpenSQLite(
|
||||
dbPath, database.SQLiteModeCreate,
|
||||
)
|
||||
t.Cleanup(func() { _ = mgr.CloseAll() })
|
||||
|
||||
db, err := mgr.GetDB(uuid.New().String())
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
db, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.AutoMigrate(
|
||||
&database.Event{},
|
||||
&database.Delivery{},
|
||||
&database.DeliveryResult{},
|
||||
&database.EventTotals{},
|
||||
&database.TargetTotals{},
|
||||
))
|
||||
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
@@ -83,20 +65,6 @@ func testEngine(
|
||||
)
|
||||
}
|
||||
|
||||
func newHTTPTargetConfig(url string) string {
|
||||
cfg := delivery.HTTPTargetConfig{URL: url}
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
panic(
|
||||
"failed to marshal HTTPTargetConfig: " +
|
||||
err.Error(),
|
||||
)
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func seedEvent(
|
||||
t *testing.T, db *gorm.DB, body string,
|
||||
) database.Event {
|
||||
@@ -135,6 +103,18 @@ func seedDelivery(
|
||||
return d
|
||||
}
|
||||
|
||||
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
|
||||
// is in the form GORM stores created_at in: the layout of
|
||||
// time.Time.String, with no " m=+…" monotonic clock reading after it.
|
||||
func assertStoredTime(t *testing.T, text string) {
|
||||
t.Helper()
|
||||
|
||||
_, err := time.Parse(
|
||||
"2006-01-02 15:04:05.999999999 -0700 MST", text,
|
||||
)
|
||||
assert.NoError(t, err, "stored as %q", text)
|
||||
}
|
||||
|
||||
// httpDeliveryFixture holds the task and delivery needed
|
||||
// for HTTP delivery tests.
|
||||
type httpDeliveryFixture struct {
|
||||
@@ -147,19 +127,19 @@ type httpDeliveryFixture struct {
|
||||
func buildHTTPFixture(
|
||||
dlv database.Delivery,
|
||||
event database.Event,
|
||||
targetID, name, config string,
|
||||
maxRetries, attemptNum int,
|
||||
targetID, name, targetURL string,
|
||||
maxAttempts, attemptNum int,
|
||||
) httpDeliveryFixture {
|
||||
task := &delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: event.WebhookID,
|
||||
TargetID: targetID,
|
||||
TargetName: name,
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetConfig: config,
|
||||
MaxRetries: maxRetries,
|
||||
AttemptNum: attemptNum,
|
||||
DeliveryID: dlv.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: event.WebhookID,
|
||||
TargetID: targetID,
|
||||
TargetName: name,
|
||||
TargetType: database.TargetTypeHTTP,
|
||||
TargetURL: targetURL,
|
||||
MaxAttempts: maxAttempts,
|
||||
AttemptNum: attemptNum,
|
||||
}
|
||||
|
||||
d := &database.Delivery{
|
||||
@@ -168,10 +148,10 @@ func buildHTTPFixture(
|
||||
Status: dlv.Status,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: name,
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: config,
|
||||
MaxRetries: maxRetries,
|
||||
Name: name,
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: targetURL,
|
||||
MaxAttempts: maxAttempts,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
@@ -238,7 +218,7 @@ func TestNotify_NonBlocking(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
e.Notify([]delivery.Task{
|
||||
e.ExportNotify([]delivery.Task{
|
||||
{DeliveryID: "overflow-1"},
|
||||
{DeliveryID: "overflow-2"},
|
||||
})
|
||||
@@ -249,7 +229,7 @@ func TestNotify_NonBlocking(t *testing.T) {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal(
|
||||
"Notify blocked when delivery channel was full",
|
||||
"notify blocked when delivery channel was full",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -281,9 +261,8 @@ func TestDeliverHTTP_Success(t *testing.T) {
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := newHTTPTargetConfig(ts.URL)
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID, "test-http", cfg, 0, 1,
|
||||
dlv, event, targetID, "test-http", ts.URL, 1, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTP(
|
||||
@@ -327,10 +306,9 @@ func TestDeliverHTTP_Failure(t *testing.T) {
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := newHTTPTargetConfig(ts.URL)
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-http-fail", cfg, 0, 1,
|
||||
"test-http-fail", ts.URL, 1, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTP(
|
||||
@@ -357,7 +335,7 @@ func TestDeliverDatabase_ImmediateSuccess(
|
||||
// The database target archives for real, so the engine needs
|
||||
// the target in the main database and a data directory.
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
tgt := env.seedDatabaseTarget(t)
|
||||
|
||||
event := seedEvent(t, db, `{"db":"target"}`)
|
||||
d := seedDatabaseTargetDelivery(t, db, event, tgt)
|
||||
@@ -458,10 +436,9 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := newHTTPTargetConfig(ts.URL)
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-http-retry", cfg, 5, 1,
|
||||
"test-http-retry", ts.URL, 5, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTP(
|
||||
@@ -479,14 +456,23 @@ func TestDeliverHTTP_WithRetries_Success(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
|
||||
// TestDeliverHTTP_MaxAttemptsExhausted proves a target with three
|
||||
// delivery attempts makes at most three: the first two failures are
|
||||
// retried, the third is final, and each attempt goes through the
|
||||
// target's circuit breaker.
|
||||
func TestDeliverHTTP_MaxAttemptsExhausted(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const maxAttempts = 3
|
||||
|
||||
db := testWebhookDB(t)
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
},
|
||||
),
|
||||
@@ -495,26 +481,101 @@ func TestDeliverHTTP_MaxRetriesExhausted(t *testing.T) {
|
||||
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
// It opens on the last failure only if every attempt reached it.
|
||||
cb := delivery.NewTestCircuitBreaker(maxAttempts, time.Minute)
|
||||
e.ExportSetCircuitBreaker(targetID, cb)
|
||||
|
||||
event := seedEvent(t, db, `{"retry":"exhaust"}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
database.DeliveryStatusRetrying,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := newHTTPTargetConfig(ts.URL)
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-http-exhaust", cfg, 3, 3,
|
||||
)
|
||||
sched := &recordingScheduler{}
|
||||
|
||||
e.ExportDeliverHTTP(
|
||||
context.TODO(), db, fix.Delivery, fix.Task,
|
||||
)
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-http-exhaust", ts.URL, maxAttempts, attempt,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTPWithScheduler(
|
||||
context.TODO(), db, fix.Delivery, fix.Task, sched,
|
||||
)
|
||||
}
|
||||
|
||||
assert.Equal(t, int32(maxAttempts), requests.Load())
|
||||
assert.Len(t, sched.delays, maxAttempts-1,
|
||||
"every failure but the last should be retried",
|
||||
)
|
||||
assertDeliveryStatus(t, db, dlv.ID,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
assert.Equal(t, delivery.CircuitOpen, cb.State(),
|
||||
"every attempt should be counted by the circuit breaker",
|
||||
)
|
||||
}
|
||||
|
||||
// TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker proves a target
|
||||
// with one delivery attempt makes exactly that attempt, even while
|
||||
// its circuit breaker is open, and that a failure is final.
|
||||
func TestDeliverHTTP_SingleAttemptSkipsCircuitBreaker(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
|
||||
var requests atomic.Int32
|
||||
|
||||
ts := httptest.NewServer(
|
||||
http.HandlerFunc(
|
||||
func(w http.ResponseWriter, _ *http.Request) {
|
||||
requests.Add(1)
|
||||
w.WriteHeader(http.StatusBadGateway)
|
||||
},
|
||||
),
|
||||
)
|
||||
defer ts.Close()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
cb := e.ExportGetCircuitBreaker(targetID)
|
||||
|
||||
for range delivery.ExportDefaultFailureThreshold {
|
||||
cb.RecordFailure()
|
||||
}
|
||||
|
||||
require.Equal(t, delivery.CircuitOpen, cb.State())
|
||||
|
||||
event := seedEvent(t, db, `{"retry":"none"}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, targetID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-http-single", ts.URL, 1, 1,
|
||||
)
|
||||
sched := &recordingScheduler{}
|
||||
|
||||
e.ExportDeliverHTTPWithScheduler(
|
||||
context.TODO(), db, fix.Delivery, fix.Task, sched,
|
||||
)
|
||||
|
||||
assert.Equal(t, int32(1), requests.Load(),
|
||||
"the open circuit breaker should not hold the attempt back",
|
||||
)
|
||||
assert.Empty(t, sched.delays, "no retry should be scheduled")
|
||||
assertDeliveryStatus(t, db, dlv.ID,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
assertDeliveryResult(
|
||||
t, db, dlv.ID, false, http.StatusBadGateway,
|
||||
)
|
||||
}
|
||||
|
||||
func TestDeliverHTTP_SchedulesRetryOnFailure(
|
||||
@@ -544,10 +605,9 @@ func TestDeliverHTTP_SchedulesRetryOnFailure(
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := newHTTPTargetConfig(ts.URL)
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-http-schedule", cfg, 5, 1,
|
||||
"test-http-schedule", ts.URL, 5, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTP(
|
||||
@@ -709,7 +769,7 @@ func seedConcurrencyTasks(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
n int,
|
||||
targetCfg string,
|
||||
targetURL string,
|
||||
) ([]database.Delivery, []delivery.Task) {
|
||||
t.Helper()
|
||||
|
||||
@@ -733,22 +793,23 @@ func seedConcurrencyTasks(
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: fmt.Sprintf("task-%d", i),
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: targetCfg,
|
||||
Name: fmt.Sprintf("task-%d", i),
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: targetURL,
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
}
|
||||
tasks[i].ID = dlv.ID
|
||||
|
||||
dtasks[i] = delivery.Task{
|
||||
DeliveryID: tasks[i].ID,
|
||||
EventID: tasks[i].EventID,
|
||||
TargetID: tasks[i].TargetID,
|
||||
TargetName: tasks[i].Target.Name,
|
||||
TargetType: tasks[i].Target.Type,
|
||||
TargetConfig: tasks[i].Target.Config,
|
||||
MaxRetries: 0,
|
||||
AttemptNum: 1,
|
||||
DeliveryID: tasks[i].ID,
|
||||
EventID: tasks[i].EventID,
|
||||
TargetID: tasks[i].TargetID,
|
||||
TargetName: tasks[i].Target.Name,
|
||||
TargetType: tasks[i].Target.Type,
|
||||
TargetURL: tasks[i].Target.URL,
|
||||
MaxAttempts: 1,
|
||||
AttemptNum: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -784,9 +845,8 @@ func TestWorkerPool_BoundedConcurrency(t *testing.T) {
|
||||
defer ts.Close()
|
||||
|
||||
e := testEngine(t, numWorkers)
|
||||
cfg := newHTTPTargetConfig(ts.URL)
|
||||
tasks, dtasks := seedConcurrencyTasks(
|
||||
t, db, numTasks, cfg,
|
||||
t, db, numTasks, ts.URL,
|
||||
)
|
||||
|
||||
runConcurrencyWorkers(
|
||||
@@ -867,12 +927,9 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
cfg := newHTTPTargetConfig(
|
||||
"http://will-not-be-called.invalid",
|
||||
)
|
||||
fix := buildHTTPFixture(
|
||||
dlv, event, targetID,
|
||||
"test-cb-block", cfg, 5, 1,
|
||||
"test-cb-block", "http://will-not-be-called.invalid", 5, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTP(
|
||||
@@ -935,9 +992,8 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
|
||||
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",
|
||||
)
|
||||
const targetURL = "http://will-not-be-called.invalid"
|
||||
|
||||
sched := &recordingScheduler{}
|
||||
|
||||
const queued, passes = 3, 4
|
||||
@@ -959,7 +1015,7 @@ func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
|
||||
|
||||
fix := buildHTTPFixture(
|
||||
row, event, targetID,
|
||||
"test-cb-half-open", cfg, 5, 1,
|
||||
"test-cb-half-open", targetURL, 5, 1,
|
||||
)
|
||||
|
||||
e.ExportDeliverHTTPWithScheduler(
|
||||
@@ -1074,62 +1130,6 @@ func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
|
||||
assert.Zero(t, cooldown, "half-open")
|
||||
}
|
||||
|
||||
func TestParseHTTPConfig_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
cfg, err := e.ExportParseHTTPConfig(
|
||||
`{"url":"https://example.com/hook",` +
|
||||
`"headers":{"X-Token":"secret"}}`,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/hook", cfg.URL)
|
||||
assert.Equal(t, "secret", cfg.Headers["X-Token"])
|
||||
}
|
||||
|
||||
func TestParseHTTPConfig_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
_, err := e.ExportParseHTTPConfig("")
|
||||
|
||||
assert.Error(t, err,
|
||||
"empty config should return error",
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseHTTPConfig_MissingURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
_, err := e.ExportParseHTTPConfig(
|
||||
`{"headers":{"X-Token":"secret"}}`,
|
||||
)
|
||||
|
||||
assert.Error(t, err,
|
||||
"config without URL should return error",
|
||||
)
|
||||
}
|
||||
|
||||
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(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -1277,7 +1277,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
cfg := &delivery.HTTPTargetConfig{
|
||||
target := &database.Target{
|
||||
URL: ts.URL,
|
||||
Headers: map[string]string{
|
||||
"X-Target-Auth": "bearer xyz",
|
||||
@@ -1292,7 +1292,7 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
||||
}
|
||||
|
||||
statusCode, _, _, err := e.ExportDoHTTPRequest(
|
||||
context.TODO(), cfg, event,
|
||||
context.TODO(), target, event,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
@@ -1334,7 +1334,7 @@ func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
|
||||
|
||||
_, body, _, err := e.ExportDoHTTPRequest(
|
||||
context.TODO(),
|
||||
&delivery.HTTPTargetConfig{URL: ts.URL},
|
||||
&database.Target{URL: ts.URL},
|
||||
&database.Event{},
|
||||
)
|
||||
|
||||
@@ -1386,9 +1386,9 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cfg := &delivery.HTTPTargetConfig{}
|
||||
var configured map[string]string
|
||||
if tc.configured != "" {
|
||||
cfg.Headers = map[string]string{
|
||||
configured = map[string]string{
|
||||
headerContentType: tc.configured,
|
||||
}
|
||||
}
|
||||
@@ -1407,7 +1407,7 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
||||
Headers: string(inbound),
|
||||
ContentType: tc.event,
|
||||
},
|
||||
cfg,
|
||||
configured,
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
@@ -1438,7 +1438,7 @@ func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
|
||||
&database.Event{
|
||||
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
|
||||
},
|
||||
&delivery.HTTPTargetConfig{},
|
||||
nil,
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
@@ -1456,7 +1456,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
|
||||
// The database target archives for real, so the engine needs
|
||||
// the target in the main database and a data directory.
|
||||
env := setupArchiveTest(t)
|
||||
archive := env.seedDatabaseTarget(t, "")
|
||||
archive := env.seedDatabaseTarget(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -1541,51 +1541,6 @@ func TestMaxInlineBodySize_Constant(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseSlackConfig_Valid(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
cfg, err := e.ExportParseSlackConfig(
|
||||
`{"webhookUrl":"https://hooks.slack.com/services/T00/B00/xxx"}`,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t,
|
||||
"https://hooks.slack.com/services/T00/B00/xxx",
|
||||
cfg.WebhookURL,
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseSlackConfig_Empty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
_, err := e.ExportParseSlackConfig("")
|
||||
|
||||
assert.Error(t, err,
|
||||
"empty config should return error",
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseSlackConfig_MissingWebhookURL(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
_, err := e.ExportParseSlackConfig(
|
||||
`{"other":"field"}`,
|
||||
)
|
||||
|
||||
assert.Error(t, err,
|
||||
"config without webhook_url should return error",
|
||||
)
|
||||
}
|
||||
|
||||
func TestFormatSlackMessage_JSONBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -1682,11 +1637,11 @@ func TestFormatSlackMessage_LargeJSONTruncated(
|
||||
}
|
||||
|
||||
// buildSlackDelivery creates a database.Delivery
|
||||
// with a Slack target config for testing.
|
||||
// to a Slack target with the given webhook URL for testing.
|
||||
func buildSlackDelivery(
|
||||
dlv database.Delivery,
|
||||
event database.Event,
|
||||
targetID, name, slackCfg string,
|
||||
targetID, name, webhookURL string,
|
||||
) *database.Delivery {
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
@@ -1694,9 +1649,10 @@ func buildSlackDelivery(
|
||||
Status: dlv.Status,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: name,
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: slackCfg,
|
||||
Name: name,
|
||||
Type: database.TargetTypeSlack,
|
||||
URL: webhookURL,
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
@@ -1733,11 +1689,10 @@ func TestDeliverSlack_Success(t *testing.T) {
|
||||
)
|
||||
defer ts.Close()
|
||||
|
||||
e, targetID, slackCfg, event, dlv :=
|
||||
setupSlackTest(t, db, ts.URL)
|
||||
e, targetID, event, dlv := setupSlackTest(t, db)
|
||||
|
||||
d := buildSlackDelivery(
|
||||
dlv, event, targetID, "test-slack", slackCfg,
|
||||
dlv, event, targetID, "test-slack", ts.URL,
|
||||
)
|
||||
|
||||
e.ExportDeliverSlack(context.TODO(), db, d)
|
||||
@@ -1756,9 +1711,8 @@ func TestDeliverSlack_Success(t *testing.T) {
|
||||
func setupSlackTest(
|
||||
t *testing.T,
|
||||
db *gorm.DB,
|
||||
serverURL string,
|
||||
) (
|
||||
*delivery.Engine, string, string,
|
||||
*delivery.Engine, string,
|
||||
database.Event, database.Delivery,
|
||||
) {
|
||||
t.Helper()
|
||||
@@ -1766,13 +1720,6 @@ func setupSlackTest(
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
cfgBytes, err := json.Marshal(
|
||||
delivery.SlackTargetConfig{
|
||||
WebhookURL: serverURL,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(
|
||||
t, db, `{"action":"test","data":"value"}`,
|
||||
)
|
||||
@@ -1782,7 +1729,7 @@ func setupSlackTest(
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
return e, targetID, string(cfgBytes), event, dlv
|
||||
return e, targetID, event, dlv
|
||||
}
|
||||
|
||||
func assertSlackPayload(
|
||||
@@ -1831,13 +1778,6 @@ func TestDeliverSlack_Failure(t *testing.T) {
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
slackCfg, err := json.Marshal(
|
||||
delivery.SlackTargetConfig{
|
||||
WebhookURL: ts.URL,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"test":true}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
@@ -1847,7 +1787,7 @@ func TestDeliverSlack_Failure(t *testing.T) {
|
||||
|
||||
d := buildSlackDelivery(
|
||||
dlv, event, targetID,
|
||||
"test-slack-fail", string(slackCfg),
|
||||
"test-slack-fail", ts.URL,
|
||||
)
|
||||
|
||||
e.ExportDeliverSlack(context.TODO(), db, d)
|
||||
@@ -1861,7 +1801,10 @@ func TestDeliverSlack_Failure(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
func TestDeliverSlack_InvalidConfig(t *testing.T) {
|
||||
// TestDeliverSlack_NoURL proves a Slack target without a URL, which
|
||||
// the forms never store, fails its delivery rather than reporting it
|
||||
// delivered.
|
||||
func TestDeliverSlack_NoURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db := testWebhookDB(t)
|
||||
@@ -1880,9 +1823,9 @@ func TestDeliverSlack_InvalidConfig(t *testing.T) {
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-slack-bad",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"not_webhook_url":"missing"}`,
|
||||
Name: "test-slack-bad",
|
||||
Type: database.TargetTypeSlack,
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
@@ -1919,13 +1862,6 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
|
||||
|
||||
e := testEngine(t, 1)
|
||||
|
||||
slackCfg, err := json.Marshal(
|
||||
delivery.SlackTargetConfig{
|
||||
WebhookURL: ts.URL,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"route":"slack"}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
@@ -1935,12 +1871,13 @@ func TestProcessDelivery_RoutesToSlack(t *testing.T) {
|
||||
|
||||
d := buildSlackDelivery(
|
||||
dlv, event, dlv.TargetID,
|
||||
"test-slack-route", string(slackCfg),
|
||||
"test-slack-route", ts.URL,
|
||||
)
|
||||
|
||||
task := &delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
TargetType: database.TargetTypeSlack,
|
||||
DeliveryID: dlv.ID,
|
||||
TargetType: database.TargetTypeSlack,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
|
||||
e.ExportProcessDelivery(
|
||||
@@ -1990,6 +1927,10 @@ func assertLogLineComplete(
|
||||
"log line must contain the full request headers",
|
||||
)
|
||||
|
||||
assert.Contains(t, out, "raw_query="+strconv.Quote(event.RawQuery),
|
||||
"log line must contain the query string",
|
||||
)
|
||||
|
||||
assert.Contains(t, out, event.EntrypointID,
|
||||
"log line must contain the entrypoint id",
|
||||
)
|
||||
@@ -2012,6 +1953,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
|
||||
event := seedEvent(
|
||||
t, db, `{"log-body-marker":"abc123"}`,
|
||||
)
|
||||
event.RawQuery = eventQuery
|
||||
|
||||
dlv := seedDelivery(
|
||||
t, db, event.ID, uuid.New().String(),
|
||||
@@ -2044,7 +1986,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
|
||||
func buildSlackRetryDelivery(
|
||||
dlv database.Delivery,
|
||||
event database.Event,
|
||||
targetID, cfg string,
|
||||
targetID, webhookURL string,
|
||||
) *database.Delivery {
|
||||
d := &database.Delivery{
|
||||
EventID: event.ID,
|
||||
@@ -2052,10 +1994,10 @@ func buildSlackRetryDelivery(
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "test-slack-retry",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: cfg,
|
||||
MaxRetries: 5,
|
||||
Name: "test-slack-retry",
|
||||
Type: database.TargetTypeSlack,
|
||||
URL: webhookURL,
|
||||
MaxAttempts: 5,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
@@ -2073,11 +2015,6 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
|
||||
e := testEngine(t, 1)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
slackCfg, err := json.Marshal(
|
||||
delivery.SlackTargetConfig{WebhookURL: ts.URL},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := seedEvent(t, db, `{"slack":"retry"}`)
|
||||
|
||||
dlv := seedDelivery(
|
||||
@@ -2086,15 +2023,15 @@ func TestDeliverSlack_WithRetries_SchedulesRetry(
|
||||
)
|
||||
|
||||
d := buildSlackRetryDelivery(
|
||||
dlv, event, targetID, string(slackCfg),
|
||||
dlv, event, targetID, ts.URL,
|
||||
)
|
||||
|
||||
task := &delivery.Task{
|
||||
DeliveryID: dlv.ID,
|
||||
TargetID: targetID,
|
||||
TargetType: database.TargetTypeSlack,
|
||||
MaxRetries: 5,
|
||||
AttemptNum: 1,
|
||||
DeliveryID: dlv.ID,
|
||||
TargetID: targetID,
|
||||
TargetType: database.TargetTypeSlack,
|
||||
MaxAttempts: 5,
|
||||
AttemptNum: 1,
|
||||
}
|
||||
|
||||
e.ExportProcessDelivery(context.TODO(), db, d, task)
|
||||
|
||||
@@ -69,7 +69,6 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
|
||||
))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
targetID := uuid.New().String()
|
||||
dbPath := s.DBMgr.DBPath(s.WebhookID)
|
||||
|
||||
@@ -85,7 +84,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
|
||||
)
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"leaky-target", cfg, 5, 1, &body,
|
||||
"leaky-target", ts.URL, 5, 1, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
@@ -102,7 +101,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/107.
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "leaky-target",
|
||||
database.TargetTypeHTTP, cfg, 5,
|
||||
database.TargetTypeHTTP, ts.URL, 5,
|
||||
)
|
||||
|
||||
rd := iSeedDelivery(
|
||||
@@ -111,7 +110,7 @@ func TestEventDBHoldsNoTargetRows(t *testing.T) {
|
||||
)
|
||||
rTask := iTask(
|
||||
rd, event, s.WebhookID, targetID,
|
||||
"leaky-target", cfg, 5, 2, &body,
|
||||
"leaky-target", ts.URL, 5, 2, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(context.TODO(), &rTask)
|
||||
@@ -138,7 +137,6 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
|
||||
))
|
||||
defer ts.Close()
|
||||
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -152,7 +150,7 @@ func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
|
||||
)
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"leaky-target", cfg, 0, 1, &body,
|
||||
"leaky-target", ts.URL, 1, 1, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
|
||||
@@ -93,17 +93,6 @@ func (s *tsSink) text(t *testing.T) string {
|
||||
}
|
||||
}
|
||||
|
||||
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".
|
||||
@@ -142,16 +131,17 @@ func tsSeedEvent(
|
||||
// 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,
|
||||
t *testing.T, mainDB *gorm.DB, webhookID, webhookURL string,
|
||||
) database.Target {
|
||||
t.Helper()
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: webhookID,
|
||||
Name: "slack-sink",
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: config,
|
||||
Active: true,
|
||||
WebhookID: webhookID,
|
||||
Name: "slack-sink",
|
||||
Type: database.TargetTypeSlack,
|
||||
URL: webhookURL,
|
||||
Active: true,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
|
||||
require.NoError(t, mainDB.Create(&target).Error)
|
||||
@@ -175,8 +165,8 @@ func tsTask(
|
||||
TargetID: target.ID,
|
||||
TargetName: target.Name,
|
||||
TargetType: database.TargetTypeSlack,
|
||||
TargetConfig: target.Config,
|
||||
MaxRetries: 0,
|
||||
TargetURL: target.URL,
|
||||
MaxAttempts: 1,
|
||||
Method: event.Method,
|
||||
Headers: event.Headers,
|
||||
ContentType: event.ContentType,
|
||||
@@ -225,8 +215,7 @@ func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
|
||||
s := newISetup(t)
|
||||
sink := newTSSink(t)
|
||||
|
||||
cfg := tsSlackConfig(t, sink.URL)
|
||||
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
||||
target := tsSeedTarget(t, s.MainDB, s.WebhookID, sink.URL)
|
||||
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
||||
|
||||
d := iSeedDelivery(
|
||||
@@ -337,8 +326,7 @@ func TestFormatSlackMessageOverTaskReconstructedEvent(
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
||||
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
||||
target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook)
|
||||
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
||||
|
||||
d := iSeedDelivery(
|
||||
@@ -409,8 +397,7 @@ func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
cfg := tsSlackConfig(t, tsUndeliverableHook)
|
||||
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
|
||||
target := tsSeedTarget(t, s.MainDB, s.WebhookID, tsUndeliverableHook)
|
||||
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
|
||||
|
||||
d := iSeedDelivery(
|
||||
|
||||
@@ -12,16 +12,11 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"go.uber.org/fx"
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/metrics"
|
||||
)
|
||||
|
||||
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
|
||||
// an evicted archive writer. It carries the Err prefix rather
|
||||
// than this file's usual Export one because it is a sentinel
|
||||
// error.
|
||||
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
|
||||
|
||||
// Exported constants for test access.
|
||||
const (
|
||||
ExportDeliveryChannelSize = deliveryChannelSize
|
||||
@@ -82,10 +77,10 @@ func ExportIsForwardableHeader(name string) bool {
|
||||
func ExportApplyRequestHeaders(
|
||||
req *http.Request,
|
||||
event *database.Event,
|
||||
cfg *HTTPTargetConfig,
|
||||
configured map[string]string,
|
||||
userAgent string,
|
||||
) []string {
|
||||
return applyRequestHeaders(req, event, cfg, userAgent)
|
||||
return applyRequestHeaders(req, event, configured, userAgent)
|
||||
}
|
||||
|
||||
// ExportTruncate exposes truncate for testing.
|
||||
@@ -142,9 +137,10 @@ func (e *Engine) ExportDeliverSlack(
|
||||
d *database.Delivery,
|
||||
) {
|
||||
task := &Task{
|
||||
DeliveryID: d.ID,
|
||||
TargetID: d.TargetID,
|
||||
AttemptNum: 1,
|
||||
DeliveryID: d.ID,
|
||||
TargetID: d.TargetID,
|
||||
MaxAttempts: d.Target.MaxAttempts,
|
||||
AttemptNum: 1,
|
||||
}
|
||||
|
||||
e.targets[database.TargetTypeSlack].Deliver(
|
||||
@@ -162,6 +158,11 @@ func (e *Engine) ExportUpdateDeliveryStatus(
|
||||
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
||||
}
|
||||
|
||||
// ExportNotify exposes notify.
|
||||
func (e *Engine) ExportNotify(tasks []Task) {
|
||||
e.notify(tasks)
|
||||
}
|
||||
|
||||
// ExportProcessNewTask exposes processNewTask.
|
||||
func (e *Engine) ExportProcessNewTask(
|
||||
ctx context.Context, task *Task,
|
||||
@@ -220,37 +221,23 @@ func (e *Engine) ExportSetSlackCircuitBreaker(
|
||||
e.slackTarget.circuitBreakers.Store(targetID, cb)
|
||||
}
|
||||
|
||||
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||
func (e *Engine) ExportParseHTTPConfig(
|
||||
configJSON string,
|
||||
) (*HTTPTargetConfig, error) {
|
||||
return parseHTTPConfig(configJSON)
|
||||
}
|
||||
|
||||
// ExportParseSlackConfig exposes parseSlackConfig.
|
||||
func (e *Engine) ExportParseSlackConfig(
|
||||
configJSON string,
|
||||
) (*SlackTargetConfig, error) {
|
||||
return parseSlackConfig(configJSON)
|
||||
}
|
||||
|
||||
// ExportDoHTTPRequest exposes the http target's
|
||||
// doHTTPRequest.
|
||||
func (e *Engine) ExportDoHTTPRequest(
|
||||
ctx context.Context,
|
||||
cfg *HTTPTargetConfig,
|
||||
target *database.Target,
|
||||
event *database.Event,
|
||||
) (int, string, int64, error) {
|
||||
return e.httpTarget.doHTTPRequest(ctx, cfg, event)
|
||||
return e.httpTarget.doHTTPRequest(ctx, target, event)
|
||||
}
|
||||
|
||||
// ExportClientForRequest exposes the http target's
|
||||
// clientForRequest.
|
||||
func (e *Engine) ExportClientForRequest(
|
||||
cfg *HTTPTargetConfig,
|
||||
timeoutSeconds int,
|
||||
originScoped []string,
|
||||
) *http.Client {
|
||||
return e.httpTarget.clientForRequest(cfg, originScoped)
|
||||
return e.httpTarget.clientForRequest(timeoutSeconds, originScoped)
|
||||
}
|
||||
|
||||
// ErrExportTooManyRedirects exposes the sentinel the redirect
|
||||
@@ -285,32 +272,27 @@ func (e *Engine) ExportRecoverPendingDeliveries(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
webhookID string,
|
||||
atStart bool,
|
||||
) {
|
||||
e.recoverPendingDeliveries(
|
||||
ctx, webhookDB, webhookID,
|
||||
ctx, webhookDB, webhookID, atStart,
|
||||
)
|
||||
}
|
||||
|
||||
// ExportRecoverWebhookDeliveries exposes
|
||||
// recoverWebhookDeliveries.
|
||||
func (e *Engine) ExportRecoverWebhookDeliveries(
|
||||
ctx context.Context, webhookID string,
|
||||
ctx context.Context, webhookID string, atStart bool,
|
||||
) {
|
||||
e.recoverWebhookDeliveries(ctx, webhookID)
|
||||
e.recoverWebhookDeliveries(ctx, webhookID, atStart)
|
||||
}
|
||||
|
||||
// ExportRecoverInFlight exposes recoverInFlight.
|
||||
func (e *Engine) ExportRecoverInFlight(
|
||||
ctx context.Context,
|
||||
// ExportRecoverDeliveries exposes recoverDeliveries, the recovery
|
||||
// routine.
|
||||
func (e *Engine) ExportRecoverDeliveries(
|
||||
ctx context.Context, atStart bool,
|
||||
) {
|
||||
e.recoverInFlight(ctx)
|
||||
}
|
||||
|
||||
// ExportSweepWebhookRetries exposes sweepWebhookRetries.
|
||||
func (e *Engine) ExportSweepWebhookRetries(
|
||||
ctx context.Context, webhookID string,
|
||||
) {
|
||||
e.sweepWebhookRetries(ctx, webhookID)
|
||||
e.recoverDeliveries(ctx, atStart)
|
||||
}
|
||||
|
||||
// ExportStart exposes start for testing.
|
||||
@@ -357,9 +339,9 @@ func (e *Engine) ExportRetainDelivery(deliveryID string) bool {
|
||||
|
||||
// ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries.
|
||||
func (e *Engine) ExportRecoverRetryingDeliveries(
|
||||
webhookDB *gorm.DB, webhookID string,
|
||||
ctx context.Context, webhookDB *gorm.DB, webhookID string,
|
||||
) {
|
||||
e.recoverRetryingDeliveries(webhookDB, webhookID)
|
||||
e.recoverRetryingDeliveries(ctx, webhookDB, webhookID)
|
||||
}
|
||||
|
||||
// ExportFailMissingTarget exposes failMissingTarget, so a test can hand
|
||||
@@ -398,7 +380,8 @@ func (e *Engine) ExportRetryCh() chan Task {
|
||||
}
|
||||
|
||||
// NewTestEngine creates an Engine for unit tests without
|
||||
// database dependencies.
|
||||
// database dependencies. Its archive writers have no data
|
||||
// directory, so a delivery to a database target fails.
|
||||
func NewTestEngine(
|
||||
log *slog.Logger,
|
||||
client *http.Client,
|
||||
@@ -410,6 +393,7 @@ func NewTestEngine(
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: workers,
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
|
||||
}
|
||||
e.initTargets(client)
|
||||
|
||||
@@ -422,9 +406,10 @@ func NewTestEngineSmallRetry(
|
||||
log *slog.Logger,
|
||||
) *Engine {
|
||||
e := &Engine{
|
||||
log: log,
|
||||
retryCh: make(chan Task, 1),
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
log: log,
|
||||
retryCh: make(chan Task, 1),
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
archives: archive.NewWriters(archive.WritersParams{Logger: log}),
|
||||
}
|
||||
e.initTargets(nil)
|
||||
|
||||
@@ -432,7 +417,8 @@ func NewTestEngineSmallRetry(
|
||||
}
|
||||
|
||||
// NewTestEngineWithDB creates an Engine with a real
|
||||
// database and dbManager for integration tests.
|
||||
// database and dbManager for integration tests, and archive
|
||||
// writers of its own over the same two.
|
||||
func NewTestEngineWithDB(
|
||||
db *database.Database,
|
||||
dbMgr *database.WebhookDBManager,
|
||||
@@ -448,12 +434,22 @@ func NewTestEngineWithDB(
|
||||
retryCh: make(chan Task, retryChannelSize),
|
||||
workers: workers,
|
||||
mtr: metrics.New(prometheus.NewRegistry()),
|
||||
archives: archive.NewWriters(archive.WritersParams{
|
||||
Database: db,
|
||||
DBManager: dbMgr,
|
||||
Logger: log,
|
||||
}),
|
||||
}
|
||||
e.initTargets(client)
|
||||
|
||||
return e
|
||||
}
|
||||
|
||||
// ExportArchives returns the engine's archive writers.
|
||||
func (e *Engine) ExportArchives() *archive.Writers {
|
||||
return e.archives
|
||||
}
|
||||
|
||||
// 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) {
|
||||
@@ -476,273 +472,3 @@ func NewTestCircuitBreaker(
|
||||
cooldown: cooldown,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportArchivedEvent aliases the archive row type so black-box
|
||||
// tests can construct and read archive rows.
|
||||
type ExportArchivedEvent = archivedEvent
|
||||
|
||||
// ExportArchiveWriter wraps an archiveWriter so black-box tests
|
||||
// can exercise the archive file mechanics.
|
||||
type ExportArchiveWriter struct {
|
||||
w *archiveWriter
|
||||
}
|
||||
|
||||
// NewExportArchiveWriter builds an archive writer for tests,
|
||||
// optionally overriding the reopen debounce (a non-positive
|
||||
// debounce keeps the production default).
|
||||
func NewExportArchiveWriter(
|
||||
path string, log *slog.Logger, debounce time.Duration,
|
||||
) *ExportArchiveWriter {
|
||||
w := newArchiveWriter(path, log)
|
||||
if debounce > 0 {
|
||||
w.debounce = debounce
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}
|
||||
}
|
||||
|
||||
// Write archives a row through the writer, into the file named
|
||||
// without a period.
|
||||
func (e *ExportArchiveWriter) Write(
|
||||
row ExportArchivedEvent, expiry time.Duration,
|
||||
) error {
|
||||
return e.w.write(row, expiry, "")
|
||||
}
|
||||
|
||||
// WritePeriod archives a row through the writer, into the file for
|
||||
// period.
|
||||
func (e *ExportArchiveWriter) WritePeriod(
|
||||
row ExportArchivedEvent, expiry time.Duration, period string,
|
||||
) error {
|
||||
return e.w.write(row, expiry, period)
|
||||
}
|
||||
|
||||
// Open opens the archive file, pruning when expiry is positive.
|
||||
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
||||
return e.w.open(e.w.path, expiry)
|
||||
}
|
||||
|
||||
// Reopen closes and reopens the archive file.
|
||||
func (e *ExportArchiveWriter) Reopen(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.reopen(e.w.path, expiry)
|
||||
}
|
||||
|
||||
// SetNow replaces the clock the writer measures its reopen
|
||||
// debounce on.
|
||||
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
|
||||
e.w.now = now
|
||||
}
|
||||
|
||||
// Reopens reports how many times the file has been opened.
|
||||
func (e *ExportArchiveWriter) Reopens() int {
|
||||
return e.w.reopens
|
||||
}
|
||||
|
||||
// DB returns the writer's current open handle for row
|
||||
// inspection in tests.
|
||||
func (e *ExportArchiveWriter) DB() *gorm.DB {
|
||||
return e.w.db
|
||||
}
|
||||
|
||||
// Path returns the archive file the writer owns.
|
||||
func (e *ExportArchiveWriter) Path() string {
|
||||
return e.w.path
|
||||
}
|
||||
|
||||
// OpenExisting opens the archive without permitting creation,
|
||||
// the way the idle sweep does.
|
||||
func (e *ExportArchiveWriter) OpenExisting(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
|
||||
}
|
||||
|
||||
// SweepExpired runs an idle sweep of the archive.
|
||||
func (e *ExportArchiveWriter) SweepExpired(
|
||||
expiry time.Duration,
|
||||
) error {
|
||||
return e.w.sweepExpired(expiry)
|
||||
}
|
||||
|
||||
// Evict marks the writer evicted and closes its handle, exactly
|
||||
// as leaving the registry does.
|
||||
func (e *ExportArchiveWriter) Evict() {
|
||||
e.w.evict()
|
||||
}
|
||||
|
||||
// Rename gives the archive file a new name in the same directory,
|
||||
// as a rename of the webhook or target does.
|
||||
func (e *ExportArchiveWriter) Rename(name string) error {
|
||||
return e.w.rename(name)
|
||||
}
|
||||
|
||||
// HandleOpen reports whether the writer currently holds an open
|
||||
// archive handle.
|
||||
func (e *ExportArchiveWriter) HandleOpen() bool {
|
||||
e.w.mu.Lock()
|
||||
defer e.w.mu.Unlock()
|
||||
|
||||
return e.w.db != nil
|
||||
}
|
||||
|
||||
// Same reports whether both wrappers refer to the very same
|
||||
// underlying archive writer, so a test can prove a registry entry
|
||||
// is the writer it was handed rather than a replacement.
|
||||
func (e *ExportArchiveWriter) Same(
|
||||
other *ExportArchiveWriter,
|
||||
) bool {
|
||||
return other != nil && e.w == other.w
|
||||
}
|
||||
|
||||
// ExportArchiveWriterFor returns the archive writer the registry
|
||||
// currently caches for a database target, or nil when none is
|
||||
// cached. It never creates one, so a test can hold a reference to
|
||||
// the very writer an eviction is about to detach.
|
||||
func (e *Engine) ExportArchiveWriterFor(
|
||||
targetID string,
|
||||
) *ExportArchiveWriter {
|
||||
e.dbTarget.mu.Lock()
|
||||
defer e.dbTarget.mu.Unlock()
|
||||
|
||||
w, ok := e.dbTarget.writers[targetID]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}
|
||||
}
|
||||
|
||||
// ExportHasArchiveWriter reports whether the database target
|
||||
// type currently caches an archive writer for a target.
|
||||
func (e *Engine) ExportHasArchiveWriter(
|
||||
targetID string,
|
||||
) bool {
|
||||
e.dbTarget.mu.Lock()
|
||||
defer e.dbTarget.mu.Unlock()
|
||||
|
||||
_, ok := e.dbTarget.writers[targetID]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// ExportArchiveHandleOpen reports whether the cached archive
|
||||
// writer for a target holds an open database handle. It
|
||||
// returns false when no writer is cached.
|
||||
func (e *Engine) ExportArchiveHandleOpen(
|
||||
targetID string,
|
||||
) bool {
|
||||
e.dbTarget.mu.Lock()
|
||||
w, ok := e.dbTarget.writers[targetID]
|
||||
e.dbTarget.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
return w.db != nil
|
||||
}
|
||||
|
||||
// ExportEnsureArchiveWriter creates (if needed) and returns the
|
||||
// archive file path of the cached writer for a target, so a
|
||||
// test can prime the registry the way a delivery would.
|
||||
func (e *Engine) ExportEnsureArchiveWriter(
|
||||
targetID string,
|
||||
) (string, error) {
|
||||
w, err := e.dbTarget.writerFor(targetID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return w.path, nil
|
||||
}
|
||||
|
||||
// ExportSweepWriterFor takes a target's registry writer exactly
|
||||
// as the idle sweep does, reporting whether the sweep had to
|
||||
// create the entry. It lets a test drive the registry through the
|
||||
// sweep's own entry point instead of choreographing goroutines.
|
||||
func (e *Engine) ExportSweepWriterFor(
|
||||
targetID string,
|
||||
) (*ExportArchiveWriter, bool, error) {
|
||||
w, created, err := e.dbTarget.sweepWriterFor(targetID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
return &ExportArchiveWriter{w: w}, created, nil
|
||||
}
|
||||
|
||||
// ExportReleaseSweepWriter releases a sweep-created registry entry
|
||||
// exactly as a finished sweep does.
|
||||
func (e *Engine) ExportReleaseSweepWriter(
|
||||
targetID string, w *ExportArchiveWriter,
|
||||
) {
|
||||
e.dbTarget.releaseSweepWriter(targetID, w.w)
|
||||
}
|
||||
|
||||
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the
|
||||
// given main database and engine, without the fx lifecycle.
|
||||
// Intended for tests.
|
||||
func NewTestArchiveSweeper(
|
||||
db *database.Database,
|
||||
eng *Engine,
|
||||
log *slog.Logger,
|
||||
) *ArchiveSweeper {
|
||||
return &ArchiveSweeper{
|
||||
db: db,
|
||||
eng: eng,
|
||||
log: log,
|
||||
interval: time.Hour,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportSweep runs a single archive sweep synchronously for
|
||||
// tests.
|
||||
func (s *ArchiveSweeper) ExportSweep(ctx context.Context) {
|
||||
s.sweep(ctx)
|
||||
}
|
||||
|
||||
// ExportStart starts the sweeper's background loop for tests.
|
||||
func (s *ArchiveSweeper) ExportStart() {
|
||||
s.start()
|
||||
}
|
||||
|
||||
// ExportRegisterHooks registers the sweeper's real fx lifecycle
|
||||
// hooks on a lifecycle supplied by a test, so a test can drive
|
||||
// the exact OnStart/OnStop functions the application runs and
|
||||
// hand OnStart the kind of context fx actually supplies.
|
||||
func (s *ArchiveSweeper) ExportRegisterHooks(lc fx.Lifecycle) {
|
||||
s.registerHooks(lc)
|
||||
}
|
||||
|
||||
// ExportStop stops the sweeper's background loop for tests.
|
||||
func (s *ArchiveSweeper) ExportStop(ctx context.Context) error {
|
||||
return s.stop(ctx)
|
||||
}
|
||||
|
||||
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
|
||||
// that never observes cancellation and returns only when release
|
||||
// is closed. It stands in for a prune stuck on a locked archive.
|
||||
func (s *ArchiveSweeper) ExportWedgeLoop(
|
||||
release <-chan struct{},
|
||||
) {
|
||||
s.wg.Go(func() {
|
||||
<-release
|
||||
})
|
||||
}
|
||||
|
||||
// ExportSetInterval overrides the sweep interval for tests.
|
||||
func (s *ArchiveSweeper) ExportSetInterval(d time.Duration) {
|
||||
s.interval = d
|
||||
}
|
||||
|
||||
// ExportParseArchiveExpiry exposes parseArchiveExpiry.
|
||||
func ExportParseArchiveExpiry(
|
||||
configJSON string,
|
||||
) (time.Duration, error) {
|
||||
return parseArchiveExpiry(configJSON)
|
||||
}
|
||||
|
||||
@@ -6,10 +6,10 @@ import "sync"
|
||||
//
|
||||
// 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.
|
||||
// it in memory. The recovery routine, at start and on every tick of the
|
||||
// sweep, re-dispatches only deliveries the set does not hold, which is
|
||||
// what makes it 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
|
||||
@@ -25,8 +25,8 @@ import "sync"
|
||||
// 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.
|
||||
// died are not in any successor's set, and the recovery routine's run
|
||||
// at start 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
|
||||
@@ -61,9 +61,9 @@ func (s *inflightSet) retain(deliveryID string) {
|
||||
|
||||
// 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.
|
||||
// delivery, which is what makes two claimants — notify and the
|
||||
// recovery routine, say — mutually exclusive rather than merely
|
||||
// atomic.
|
||||
func (s *inflightSet) retainIdle(deliveryID string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
@@ -2,8 +2,6 @@ package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -32,7 +30,7 @@ func fSweepSetup(
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, name,
|
||||
database.TargetTypeLog, "", 0,
|
||||
database.TargetTypeLog, "", 1,
|
||||
)
|
||||
|
||||
require.NoError(t, s.MainDB.Create(&database.Webhook{
|
||||
@@ -48,8 +46,9 @@ func fSweepSetup(
|
||||
//
|
||||
// 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.
|
||||
// by now, and nothing is waited for. A timer here would race the queued
|
||||
// tasks: on a busy host it can be due by the time select looks, and
|
||||
// select picks at random among the cases that are ready.
|
||||
func fDrain(e *delivery.Engine) []delivery.Task {
|
||||
var out []delivery.Task
|
||||
|
||||
@@ -59,39 +58,12 @@ func fDrain(e *delivery.Engine) []delivery.Task {
|
||||
out = append(out, task)
|
||||
case task := <-e.ExportRetryCh():
|
||||
out = append(out, task)
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
default:
|
||||
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
|
||||
@@ -112,9 +84,9 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
|
||||
)
|
||||
rAgePending(t, s.WebhookDB, d.ID)
|
||||
|
||||
// Queued exactly as the receiver queues it, and never dequeued:
|
||||
// Queued exactly as StoreEvent queues it, and never dequeued:
|
||||
// no workers are running in this engine.
|
||||
s.Engine.Notify([]delivery.Task{{
|
||||
s.Engine.ExportNotify([]delivery.Task{{
|
||||
DeliveryID: d.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: s.WebhookID,
|
||||
@@ -123,8 +95,8 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
|
||||
|
||||
require.Equal(t, 1, s.Engine.ExportInflightHeld())
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
tasks := fDrain(s.Engine)
|
||||
@@ -135,9 +107,11 @@ func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestRecoveryAndSweepDoNotDoubleDispatch drives the two entry points
|
||||
// the engine starts concurrently against one aged pending row. Before
|
||||
// ownership they both dispatched it.
|
||||
// TestRecoveryAndSweepDoNotDoubleDispatch drives the recovery routine's
|
||||
// run at start and a tick of the sweep at once against one aged pending
|
||||
// row. The engine runs them one after the other, but notify can race
|
||||
// either, and ownership is what keeps any two claimants from both
|
||||
// sending. Before ownership the two runs both dispatched the row.
|
||||
func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -161,12 +135,12 @@ func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
|
||||
|
||||
wg.Go(func() {
|
||||
s.Engine.ExportRecoverPendingDeliveries(
|
||||
ctx, s.WebhookDB, s.WebhookID,
|
||||
ctx, s.WebhookDB, s.WebhookID, atStart,
|
||||
)
|
||||
})
|
||||
wg.Go(func() {
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
ctx, s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
ctx, s.WebhookID, onTick,
|
||||
)
|
||||
})
|
||||
wg.Wait()
|
||||
@@ -228,7 +202,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "released",
|
||||
database.TargetTypeLog, "", 0,
|
||||
database.TargetTypeLog, "", 1,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -250,7 +224,7 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
|
||||
|
||||
body := `{"released":true}`
|
||||
|
||||
s.Engine.Notify([]delivery.Task{{
|
||||
s.Engine.ExportNotify([]delivery.Task{{
|
||||
DeliveryID: d.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: s.WebhookID,
|
||||
@@ -274,10 +248,11 @@ func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// https://git.eeqj.de/sneak/webhooker/issues/299. StoreEvent has
|
||||
// written a delivery, the recovery routine's run at start finds it
|
||||
// pending, sends it and releases it, and only then does StoreEvent's
|
||||
// notify for it arrive. Nothing owns the delivery by then, so notify
|
||||
// takes it.
|
||||
func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -301,7 +276,8 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
|
||||
)
|
||||
}()
|
||||
|
||||
// Restart recovery sends the delivery and lets it go.
|
||||
// The recovery routine's run at start sends the delivery and lets
|
||||
// it go.
|
||||
iWaitForDelivered(t, s.WebhookDB, d.ID)
|
||||
require.Eventually(
|
||||
t,
|
||||
@@ -313,7 +289,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
|
||||
|
||||
body := event.Body
|
||||
|
||||
s.Engine.Notify([]delivery.Task{{
|
||||
s.Engine.ExportNotify([]delivery.Task{{
|
||||
DeliveryID: d.ID,
|
||||
EventID: event.ID,
|
||||
WebhookID: s.WebhookID,
|
||||
@@ -324,7 +300,7 @@ func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
|
||||
EntrypointID: event.EntrypointID,
|
||||
}})
|
||||
|
||||
// Notify took the delivery, and a worker releases it once it has
|
||||
// notify took the delivery, and a worker releases it once it has
|
||||
// run the task.
|
||||
require.Eventually(
|
||||
t,
|
||||
@@ -362,7 +338,7 @@ func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) {
|
||||
rSeedResult(t, s.WebhookDB, d.ID, 2, true)
|
||||
|
||||
s.Engine.ExportRecoverRetryingDeliveries(
|
||||
s.WebhookDB, s.WebhookID,
|
||||
context.Background(), s.WebhookDB, s.WebhookID,
|
||||
)
|
||||
|
||||
assert.Empty(
|
||||
@@ -377,8 +353,8 @@ func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// TestRetryingSweepSkipsASuccessfulResult is the same rule on the
|
||||
// periodic sweep's retrying arm.
|
||||
// TestRetryingSweepSkipsASuccessfulResult is the same rule on a tick of
|
||||
// the sweep.
|
||||
func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -396,8 +372,8 @@ func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) {
|
||||
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
|
||||
rSeedResult(t, s.WebhookDB, d.ID, 2, true)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
assert.Empty(t, fDrain(s.Engine))
|
||||
@@ -448,8 +424,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
|
||||
|
||||
require.Equal(t, 1, s.Engine.ExportInflightHeld())
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
assert.Empty(
|
||||
@@ -461,7 +437,8 @@ func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// next tick a minute later. The stamp is stored in the same form as
|
||||
// created_at.
|
||||
func TestRedispatchStampsTheRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -480,7 +457,7 @@ func TestRedispatchStampsTheRow(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick)
|
||||
require.Len(t, fDrain(s.Engine), 1)
|
||||
|
||||
var row database.Delivery
|
||||
@@ -492,4 +469,13 @@ func TestRedispatchStampsTheRow(t *testing.T) {
|
||||
"a re-dispatched delivery must be stamped so the "+
|
||||
"next tick does not select it again",
|
||||
)
|
||||
|
||||
var createdAt, updatedAt string
|
||||
|
||||
require.NoError(t, s.WebhookDB.Raw(
|
||||
`SELECT CAST(created_at AS TEXT), CAST(updated_at AS TEXT)
|
||||
FROM deliveries WHERE id = ?`, d.ID,
|
||||
).Row().Scan(&createdAt, &updatedAt))
|
||||
assertStoredTime(t, createdAt)
|
||||
assertStoredTime(t, updatedAt)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ func mDeliverOK(t *testing.T, s iSetup) {
|
||||
body := event.Body
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-ok", iHTTPConfig(ts.URL), 3, 1, &body,
|
||||
"metrics-ok", ts.URL, 3, 1, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
@@ -226,18 +226,17 @@ func mExhaustRetries(t *testing.T, s iSetup) {
|
||||
)
|
||||
|
||||
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,
|
||||
database.TargetTypeHTTP, ts.URL, 2,
|
||||
)
|
||||
|
||||
first := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-fail", cfg, 2, 1, &body,
|
||||
"metrics-fail", ts.URL, 2, 1, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
||||
@@ -251,7 +250,7 @@ func mExhaustRetries(t *testing.T, s iSetup) {
|
||||
// deterministic and off the wall clock.
|
||||
second := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-fail", cfg, 2, 2, &body,
|
||||
"metrics-fail", ts.URL, 2, 2, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(
|
||||
@@ -289,22 +288,21 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
|
||||
)
|
||||
|
||||
body := event.Body
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
|
||||
// A retry budget above the failure threshold, so the breaker
|
||||
// An attempt budget above the failure threshold, so the breaker
|
||||
// rather than the budget is what stops the delivery.
|
||||
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
||||
maxAttempts := 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,
|
||||
database.TargetTypeHTTP, ts.URL, maxAttempts,
|
||||
)
|
||||
|
||||
first := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-trip", cfg, maxRetries, 1, &body,
|
||||
"metrics-trip", ts.URL, maxAttempts, 1, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
||||
@@ -316,7 +314,7 @@ func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
|
||||
ExportDefaultFailureThreshold; attempt++ {
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-trip", cfg, maxRetries, attempt, &body,
|
||||
"metrics-trip", ts.URL, maxAttempts, attempt, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(
|
||||
@@ -363,17 +361,16 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
||||
)
|
||||
|
||||
body := event.Body
|
||||
cfg := iHTTPConfig(ts.URL)
|
||||
maxRetries := delivery.ExportDefaultFailureThreshold + 5
|
||||
maxAttempts := delivery.ExportDefaultFailureThreshold + 5
|
||||
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
|
||||
database.TargetTypeHTTP, cfg, maxRetries,
|
||||
database.TargetTypeHTTP, ts.URL, maxAttempts,
|
||||
)
|
||||
|
||||
first := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-blocked", cfg, maxRetries, 1, &body,
|
||||
"metrics-blocked", ts.URL, maxAttempts, 1, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &first)
|
||||
@@ -382,7 +379,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
||||
ExportDefaultFailureThreshold; attempt++ {
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-blocked", cfg, maxRetries, attempt, &body,
|
||||
"metrics-blocked", ts.URL, maxAttempts, attempt, &body,
|
||||
)
|
||||
|
||||
s.Engine.ExportProcessRetryTask(context.TODO(), &task)
|
||||
@@ -405,7 +402,7 @@ func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
|
||||
|
||||
blocked := iTask(
|
||||
d, event, s.WebhookID, targetID,
|
||||
"metrics-blocked", cfg, maxRetries,
|
||||
"metrics-blocked", ts.URL, maxAttempts,
|
||||
delivery.ExportDefaultFailureThreshold+1, &body,
|
||||
)
|
||||
|
||||
@@ -444,8 +441,8 @@ func TestDeliveryMetrics_OrphanedRetryFailureLabelled(
|
||||
t, s, database.TargetTypeLog,
|
||||
)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
iAssertStatus(t, s.WebhookDB, deliveryID,
|
||||
@@ -474,7 +471,7 @@ func TestDeliveryMetrics_QueueDepthGauges(t *testing.T) {
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID, s.WebhookID,
|
||||
"queue-depth-target", database.TargetTypeHTTP,
|
||||
iHTTPConfig("https://example.com/hook"), 3,
|
||||
"https://example.com/hook", 3,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
|
||||
@@ -20,7 +20,8 @@ const queueDepthSampleInterval = 30 * time.Second
|
||||
// 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.
|
||||
// it is the same whole-database walk each tick of the sweep already
|
||||
// makes.
|
||||
func (e *Engine) queueDepthSampler(ctx context.Context) {
|
||||
defer e.wg.Done()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
@@ -14,11 +13,9 @@ import (
|
||||
"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/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
// qdAggregateMarker identifies the queue-depth aggregate in the
|
||||
@@ -49,27 +46,13 @@ func (q *qdSyncBuf) String() 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 {
|
||||
func qdMainDB(t *testing.T, log *slog.Logger) *database.Database {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := database.OpenSQLite(
|
||||
filepath.Join(t.TempDir(), "main-gormlog.db"),
|
||||
database.SQLiteModeCreate,
|
||||
)
|
||||
db, err := database.Open(t.TempDir(), log)
|
||||
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{},
|
||||
))
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
return db
|
||||
}
|
||||
@@ -106,20 +89,20 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
|
||||
))
|
||||
|
||||
mainDB := qdMainDB(t, log)
|
||||
dbMgr := database.NewTestWebhookDBManagerWithLogger(
|
||||
t.TempDir(), log,
|
||||
dbMgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||
t, t.TempDir(), log,
|
||||
)
|
||||
|
||||
webhookID := uuid.New().String()
|
||||
webhookDB := iSeedWebhookDB(t, dbMgr, webhookID)
|
||||
|
||||
iCreateWebhook(t, mainDB, webhookID, "queue-depth-gormlog")
|
||||
iCreateWebhook(t, mainDB.DB(), webhookID, "queue-depth-gormlog")
|
||||
|
||||
targetID := uuid.New().String()
|
||||
|
||||
iCreateTarget(t, mainDB, targetID, webhookID,
|
||||
iCreateTarget(t, mainDB.DB(), targetID, webhookID,
|
||||
"queue-depth-gormlog-target", database.TargetTypeHTTP,
|
||||
iHTTPConfig("https://example.com/hook"), 3,
|
||||
"https://example.com/hook", 3,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -136,7 +119,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
|
||||
)
|
||||
|
||||
engine := delivery.NewTestEngineWithDB(
|
||||
database.NewTestDatabase(mainDB),
|
||||
mainDB,
|
||||
dbMgr,
|
||||
log,
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "already-delivered",
|
||||
database.TargetTypeLog, "", 0,
|
||||
database.TargetTypeLog, "", 1,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -88,7 +88,7 @@ func TestRecoverySkipsPendingWithSuccessfulResult(
|
||||
)
|
||||
|
||||
s.Engine.ExportRecoverPendingDeliveries(
|
||||
context.Background(), s.WebhookDB, s.WebhookID,
|
||||
context.Background(), s.WebhookDB, s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -129,7 +129,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
|
||||
|
||||
iCreateTarget(t, s.MainDB, targetID,
|
||||
s.WebhookID, "numbering",
|
||||
database.TargetTypeLog, "", 0,
|
||||
database.TargetTypeLog, "", 1,
|
||||
)
|
||||
|
||||
event := iSeedEvent(
|
||||
@@ -145,7 +145,7 @@ func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
|
||||
rSeedResult(t, s.WebhookDB, d.ID, 2, false)
|
||||
|
||||
s.Engine.ExportRecoverPendingDeliveries(
|
||||
context.Background(), s.WebhookDB, s.WebhookID,
|
||||
context.Background(), s.WebhookDB, s.WebhookID, atStart,
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -183,8 +183,8 @@ func TestSweepRecoversStrandedPending(t *testing.T) {
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -230,7 +230,7 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick)
|
||||
|
||||
select {
|
||||
case task := <-s.Engine.ExportDeliveryCh():
|
||||
@@ -246,7 +246,7 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
|
||||
s.Engine.ExportRecoverWebhookDeliveries(ctx, s.WebhookID, onTick)
|
||||
|
||||
select {
|
||||
case task := <-s.Engine.ExportDeliveryCh():
|
||||
@@ -258,9 +258,9 @@ func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// TestSweepSettlesStrandedPendingWithoutResending is the reconcile on a
|
||||
// tick of the sweep: a stranded delivery holding a successful result is
|
||||
// settled where it stands, and the receiver hears nothing.
|
||||
func TestSweepSettlesStrandedPendingWithoutResending(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -280,8 +280,8 @@ func TestSweepSettlesStrandedPendingWithoutResending(
|
||||
rSeedResult(t, s.WebhookDB, d.ID, 1, true)
|
||||
rAgePending(t, s.WebhookDB, d.ID)
|
||||
|
||||
s.Engine.ExportSweepWebhookRetries(
|
||||
context.Background(), s.WebhookID,
|
||||
s.Engine.ExportRecoverWebhookDeliveries(
|
||||
context.Background(), s.WebhookID, onTick,
|
||||
)
|
||||
|
||||
select {
|
||||
@@ -312,8 +312,9 @@ func TestSweepSettlesStrandedPendingWithoutResending(
|
||||
|
||||
// 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.
|
||||
// the status, because pending and retrying are the states the recovery
|
||||
// routine recovers and delivered is a claim the database refused to
|
||||
// record.
|
||||
func TestFailedResultWriteLeavesDeliveryRecoverable(
|
||||
t *testing.T,
|
||||
) {
|
||||
@@ -354,16 +355,17 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "unwritable",
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: iHTTPConfig(ts.URL),
|
||||
Name: "unwritable",
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: ts.URL,
|
||||
MaxAttempts: 1,
|
||||
},
|
||||
}
|
||||
full.ID = d.ID
|
||||
|
||||
s.Engine.ExportDeliverHTTP(
|
||||
context.Background(), s.WebhookDB, full,
|
||||
&delivery.Task{DeliveryID: d.ID, AttemptNum: 1},
|
||||
&delivery.Task{DeliveryID: d.ID, MaxAttempts: 1, AttemptNum: 1},
|
||||
)
|
||||
|
||||
assert.Equal(
|
||||
@@ -444,10 +446,10 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: "unwritable",
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: iHTTPConfig(ts.URL),
|
||||
MaxRetries: 3,
|
||||
Name: "unwritable",
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: ts.URL,
|
||||
MaxAttempts: 3,
|
||||
},
|
||||
}
|
||||
full.ID = d.ID
|
||||
@@ -457,9 +459,8 @@ func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
||||
s.Engine.ExportDeliverHTTPWithScheduler(
|
||||
context.Background(), s.WebhookDB, full,
|
||||
&delivery.Task{
|
||||
DeliveryID: d.ID,
|
||||
TargetID: targetID,
|
||||
AttemptNum: 1,
|
||||
DeliveryID: d.ID, TargetID: targetID,
|
||||
MaxAttempts: 3, AttemptNum: 1,
|
||||
},
|
||||
sched,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"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/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// These tests cover the recovery routine, which the engine runs once
|
||||
// when it starts and then on every tick of the sweep
|
||||
// (https://git.eeqj.de/sneak/webhooker/issues/542). Every case runs it
|
||||
// both ways, each on a webhook of its own.
|
||||
|
||||
// atStart and onTick are the two values of the recovery routine's
|
||||
// atStart parameter: its run when the engine starts, and a tick of the
|
||||
// sweep.
|
||||
const (
|
||||
atStart = true
|
||||
onTick = false
|
||||
)
|
||||
|
||||
// recoveryRun names one of the two runs of the recovery routine.
|
||||
type recoveryRun struct {
|
||||
name string
|
||||
atStart bool
|
||||
}
|
||||
|
||||
// recoveryRuns returns the routine's run at start and a tick.
|
||||
func recoveryRuns() []recoveryRun {
|
||||
return []recoveryRun{
|
||||
{"at start", atStart},
|
||||
{"on a tick", onTick},
|
||||
}
|
||||
}
|
||||
|
||||
// recoverySetup gives a fresh webhook its main-database row and an http
|
||||
// target with retries, and returns the setup and the target's ID. No
|
||||
// worker runs, so whatever the routine queues stays in the channels for
|
||||
// the test to read, and nothing is sent to the target.
|
||||
func recoverySetup(t *testing.T) (iSetup, string) {
|
||||
t.Helper()
|
||||
|
||||
s := newISetup(t)
|
||||
targetID := uuid.New().String()
|
||||
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery")
|
||||
iCreateTarget(
|
||||
t, s.MainDB, targetID, s.WebhookID, "recovery",
|
||||
database.TargetTypeHTTP,
|
||||
"http://example.com/hook", 20,
|
||||
)
|
||||
|
||||
return s, targetID
|
||||
}
|
||||
|
||||
// seedRecoveryDelivery seeds a delivery at status with the given
|
||||
// recorded attempts, failed but for the last when lastSucceeded is set,
|
||||
// all recorded now.
|
||||
func seedRecoveryDelivery(
|
||||
t *testing.T,
|
||||
s iSetup,
|
||||
targetID string,
|
||||
status database.DeliveryStatus,
|
||||
attempts int,
|
||||
lastSucceeded bool,
|
||||
) string {
|
||||
t.Helper()
|
||||
|
||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"recovery":true}`)
|
||||
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
|
||||
|
||||
for n := 1; n <= attempts; n++ {
|
||||
rSeedResult(
|
||||
t, s.WebhookDB, d.ID, n, lastSucceeded && n == attempts,
|
||||
)
|
||||
}
|
||||
|
||||
return d.ID
|
||||
}
|
||||
|
||||
// backdateAttempts moves every recorded attempt of a delivery an hour
|
||||
// into the past, long past the backoff after its first attempt.
|
||||
func backdateAttempts(t *testing.T, s iSetup, deliveryID string) {
|
||||
t.Helper()
|
||||
|
||||
require.NoError(t, s.WebhookDB.Model(&database.DeliveryResult{}).
|
||||
Where("delivery_id = ?", deliveryID).
|
||||
UpdateColumn("created_at", s.WebhookDB.NowFunc().Add(-time.Hour)).
|
||||
Error)
|
||||
}
|
||||
|
||||
// requireQueued waits for a task on ch. A retrying delivery reaches the
|
||||
// retry channel through a timer, even one due now, so it is waited for.
|
||||
func requireQueued(
|
||||
t *testing.T, ch <-chan delivery.Task,
|
||||
) delivery.Task {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case task := <-ch:
|
||||
return task
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the recovery routine queued nothing")
|
||||
|
||||
return delivery.Task{}
|
||||
}
|
||||
}
|
||||
|
||||
// assertNothingQueued asserts the engine queues nothing, giving a retry
|
||||
// timer that is due now the moment it needs to fire.
|
||||
func assertNothingQueued(t *testing.T, e *delivery.Engine) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case task := <-e.ExportDeliveryCh():
|
||||
t.Errorf("queued pending delivery %s", task.DeliveryID)
|
||||
case task := <-e.ExportRetryCh():
|
||||
t.Errorf("queued retrying delivery %s", task.DeliveryID)
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_RetryingBackoffPassed: a retrying delivery whose backoff
|
||||
// is over is queued for its next attempt at once.
|
||||
func TestRecovery_RetryingBackoffPassed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, targetID := recoverySetup(t)
|
||||
id := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusRetrying, 1, false,
|
||||
)
|
||||
backdateAttempts(t, s, id)
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
task := requireQueued(t, s.Engine.ExportRetryCh())
|
||||
assert.Equal(t, id, task.DeliveryID)
|
||||
assert.Equal(t, 2, task.AttemptNum)
|
||||
iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusRetrying)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_RetryingBackoffNotPassed: a retrying delivery whose last
|
||||
// attempt was just now waits out the rest of its backoff, ten attempts
|
||||
// in, eight and a half minutes. It is not sent now, and the engine holds
|
||||
// it, so a later tick leaves it to the timer.
|
||||
func TestRecovery_RetryingBackoffNotPassed(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, targetID := recoverySetup(t)
|
||||
id := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusRetrying, 10, false,
|
||||
)
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
assertNothingQueued(t, s.Engine)
|
||||
iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusRetrying)
|
||||
assert.Equal(t, 1, s.Engine.ExportInflightHeld(),
|
||||
"no retry timer holds the delivery",
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_PendingOlderThanTheAgeBound: a pending delivery untouched
|
||||
// for longer than the sweep's age bound is sent by either run.
|
||||
func TestRecovery_PendingOlderThanTheAgeBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, targetID := recoverySetup(t)
|
||||
id := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusPending, 0, false,
|
||||
)
|
||||
rAgePending(t, s.WebhookDB, id)
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
task := requireQueued(t, s.Engine.ExportDeliveryCh())
|
||||
assert.Equal(t, id, task.DeliveryID)
|
||||
assert.Equal(t, 1, task.AttemptNum)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_TaskCarriesEveryTargetSetting: the task the recovery
|
||||
// routine queues carries every one of the target's settings, all six
|
||||
// set here.
|
||||
// They are copied into the task one field at a time, and a field left
|
||||
// out is sent empty without any error.
|
||||
func TestRecovery_TaskCarriesEveryTargetSetting(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
iCreateWebhook(t, s.MainDB, s.WebhookID, "recovery")
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: s.WebhookID,
|
||||
Name: "recovery",
|
||||
Type: database.TargetTypeHTTP,
|
||||
Active: true,
|
||||
URL: "http://example.com/hook",
|
||||
Headers: map[string]string{headerAuthorization: "Bearer t"},
|
||||
TimeoutSeconds: 7,
|
||||
ForwardQuery: true,
|
||||
ArchiveExpiry: time.Hour,
|
||||
ArchiveRotation: "daily",
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
require.NoError(t, s.MainDB.Create(&target).Error)
|
||||
|
||||
id := seedRecoveryDelivery(
|
||||
t, s, target.ID, database.DeliveryStatusPending, 0, false,
|
||||
)
|
||||
rAgePending(t, s.WebhookDB, id)
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
task := requireQueued(t, s.Engine.ExportDeliveryCh())
|
||||
assert.Equal(t, target.ID, task.TargetID)
|
||||
assert.Equal(t, target.URL, task.TargetURL)
|
||||
assert.Equal(t, target.Headers, task.TargetHeaders)
|
||||
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
|
||||
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
|
||||
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
|
||||
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_PendingYoungerThanTheAgeBound: a pending delivery touched
|
||||
// a moment ago, as one a stop cut off is, is sent at start, which is how
|
||||
// it reaches its target on the next start
|
||||
// (https://git.eeqj.de/sneak/webhooker/issues/527). A tick leaves it.
|
||||
func TestRecovery_PendingYoungerThanTheAgeBound(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, targetID := recoverySetup(t)
|
||||
id := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusPending, 0, false,
|
||||
)
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
if run.atStart {
|
||||
task := requireQueued(t, s.Engine.ExportDeliveryCh())
|
||||
assert.Equal(t, id, task.DeliveryID)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
assertNothingQueued(t, s.Engine)
|
||||
iAssertStatus(t, s.WebhookDB, id, database.DeliveryStatusPending)
|
||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_AlreadySucceeded: a pending or retrying delivery whose
|
||||
// success is recorded but whose status write was lost is marked
|
||||
// delivered where it stands, and not sent again.
|
||||
func TestRecovery_AlreadySucceeded(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, targetID := recoverySetup(t)
|
||||
pending := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusPending, 1, true,
|
||||
)
|
||||
rAgePending(t, s.WebhookDB, pending)
|
||||
|
||||
retrying := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusRetrying, 2, true,
|
||||
)
|
||||
backdateAttempts(t, s, retrying)
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
assertNothingQueued(t, s.Engine)
|
||||
|
||||
for id, attempts := range map[string]int{pending: 1, retrying: 2} {
|
||||
iAssertStatus(
|
||||
t, s.WebhookDB, id, database.DeliveryStatusDelivered,
|
||||
)
|
||||
assert.Len(t, iResults(t, s.WebhookDB, id), attempts,
|
||||
"marking the delivery delivered recorded an attempt",
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecovery_MissingDatabase: a tick leaves a webhook whose database
|
||||
// file is missing alone, and the run at start opens it, which creates an
|
||||
// empty one and reports the loss (see
|
||||
// TestRecoverDeliveries_ReportsAMissingWebhookDatabase).
|
||||
func TestRecovery_MissingDatabase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
mainDB := iMainDB(t)
|
||||
webhookID := uuid.New().String()
|
||||
iCreateWebhook(t, mainDB.DB(), webhookID, "missing-database")
|
||||
|
||||
dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
||||
|
||||
engine := delivery.NewTestEngineWithDB(
|
||||
mainDB, dbMgr,
|
||||
slog.New(slog.DiscardHandler),
|
||||
&http.Client{Timeout: 5 * time.Second}, 1,
|
||||
)
|
||||
|
||||
engine.ExportRecoverDeliveries(context.Background(), onTick)
|
||||
assert.False(t, dbMgr.DBExists(webhookID),
|
||||
"a tick created the missing database",
|
||||
)
|
||||
|
||||
engine.ExportRecoverDeliveries(context.Background(), atStart)
|
||||
assert.True(t, dbMgr.DBExists(webhookID),
|
||||
"the run at start did not open the missing database",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRecovery_LeavesADeliveryTheEngineHolds: a delivery the engine is
|
||||
// working on, has queued, or holds a retry timer for is left alone by
|
||||
// either run, pending and retrying alike, however old.
|
||||
func TestRecovery_LeavesADeliveryTheEngineHolds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, run := range recoveryRuns() {
|
||||
t.Run(run.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s, targetID := recoverySetup(t)
|
||||
pending := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusPending, 0, false,
|
||||
)
|
||||
rAgePending(t, s.WebhookDB, pending)
|
||||
|
||||
retrying := seedRecoveryDelivery(
|
||||
t, s, targetID, database.DeliveryStatusRetrying, 1, false,
|
||||
)
|
||||
backdateAttempts(t, s, retrying)
|
||||
|
||||
require.True(t, s.Engine.ExportRetainDelivery(pending))
|
||||
require.True(t, s.Engine.ExportRetainDelivery(retrying))
|
||||
|
||||
s.Engine.ExportRecoverDeliveries(context.Background(), run.atStart)
|
||||
|
||||
assertNothingQueued(t, s.Engine)
|
||||
iAssertStatus(t, s.WebhookDB, pending, database.DeliveryStatusPending)
|
||||
iAssertStatus(t, s.WebhookDB, retrying, database.DeliveryStatusRetrying)
|
||||
assert.Equal(t, 2, s.Engine.ExportInflightHeld())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,19 +85,14 @@ func deliverWithProbeHeaders(
|
||||
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,
|
||||
"redirect-target", targetURL, 1, 1, &body,
|
||||
)
|
||||
task.TargetHeaders = map[string]string{
|
||||
probeHeaderName: probeHeaderValue,
|
||||
}
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
|
||||
@@ -309,8 +304,7 @@ func TestRedirectPolicy_StopsAtHopCap(t *testing.T) {
|
||||
)
|
||||
|
||||
client := engine.ExportClientForRequest(
|
||||
&delivery.HTTPTargetConfig{URL: srv.URL},
|
||||
[]string{probeHeaderName},
|
||||
0, []string{probeHeaderName},
|
||||
)
|
||||
require.NotNil(t, client.CheckRedirect)
|
||||
|
||||
@@ -350,7 +344,7 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
||||
inbound, err := json.Marshal(map[string][]string{
|
||||
inboundHeaderName: {inboundHeaderValue},
|
||||
"Content-Type": {testContentType},
|
||||
"User-Agent": {"curl/8.7.1"},
|
||||
headerUserAgent: {"curl/8.7.1"},
|
||||
"Host": {"inbound.example.com"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -369,11 +363,9 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
|
||||
Headers: string(inbound),
|
||||
ContentType: testContentType,
|
||||
},
|
||||
&delivery.HTTPTargetConfig{
|
||||
Headers: map[string]string{
|
||||
probeHeaderName: probeHeaderValue,
|
||||
"Content-Type": testContentType,
|
||||
},
|
||||
map[string]string{
|
||||
probeHeaderName: probeHeaderValue,
|
||||
"Content-Type": testContentType,
|
||||
},
|
||||
"webhooker/dev",
|
||||
)
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
package delivery
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// StoreEvent stores event with one pending delivery for each of its
|
||||
// webhook's active targets, and adds them to the totals, in one
|
||||
// transaction, then queues the deliveries. It returns how many
|
||||
// deliveries it created.
|
||||
//
|
||||
// The receiver stores every event it accepts through here, and a
|
||||
// resubmit stores its copy of a stored event, so every event is
|
||||
// stored, retried, SSRF-guarded and circuit-broken the same way however
|
||||
// it entered the system. CreateReplay is the only other place a
|
||||
// delivery is created.
|
||||
//
|
||||
// The targets are the webhook's active ones as they stand now: a
|
||||
// target created after a resubmitted event first arrived is included,
|
||||
// and an inactive one is skipped. An event with no active target is
|
||||
// stored with no deliveries.
|
||||
func (e *Engine) StoreEvent(event *database.Event) (int, error) {
|
||||
var targets []database.Target
|
||||
|
||||
err := e.database.DB().Where(
|
||||
"webhook_id = ? AND active = ?", event.WebhookID, true,
|
||||
).Find(&targets).Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("loading active targets: %w", err)
|
||||
}
|
||||
|
||||
webhookDB, err := e.dbManager.GetDB(event.WebhookID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("getting webhook database: %w", err)
|
||||
}
|
||||
|
||||
event.BodyBytes = int64(len(event.Body))
|
||||
tasks := make([]Task, 0, len(targets))
|
||||
|
||||
err = webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
err := tx.Create(event).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating event: %w", err)
|
||||
}
|
||||
|
||||
for i := range targets {
|
||||
dlv, err := createDelivery(tx, event.ID, targets[i].ID, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tasks = append(tasks, buildTask(
|
||||
dlv, event.WebhookID, event, &targets[i], 1,
|
||||
))
|
||||
}
|
||||
|
||||
err = database.AddEventTotals(tx, database.EventTotals{
|
||||
Events: 1, LastEventAt: &event.CreatedAt,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A resubmitted copy did not arrive on its entrypoint's URL, so
|
||||
// it leaves the entrypoint's last event as it is.
|
||||
if event.ResubmittedFromID != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return database.AddEntrypointTotals(tx, database.EntrypointTotals{
|
||||
EntrypointID: event.EntrypointID, LastEventAt: event.CreatedAt,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Counted here, after the commit: an event exists once it is
|
||||
// durably stored, which is what the delivery counters are compared
|
||||
// against on a dashboard. A resubmitted event counts too, because
|
||||
// it produces deliveries that the delivery side counts; the
|
||||
// resubmit counter is what separates the two.
|
||||
e.mtr.EventReceived()
|
||||
|
||||
e.notify(tasks)
|
||||
|
||||
return len(tasks), nil
|
||||
}
|
||||
|
||||
// CreateReplay creates one more pending delivery of the stored event
|
||||
// eventID to target, marked as a replay, adds it to the target's totals
|
||||
// in the same transaction, and queues it. It returns the new
|
||||
// delivery's id.
|
||||
//
|
||||
// The caller passes the target as it stands now and has already
|
||||
// decided the replay may go ahead. The delivery being replayed is not
|
||||
// touched.
|
||||
func (e *Engine) CreateReplay(
|
||||
webhookID, eventID string, target *database.Target,
|
||||
) (string, error) {
|
||||
webhookDB, err := e.dbManager.GetDB(webhookID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("getting webhook database: %w", err)
|
||||
}
|
||||
|
||||
event, err := e.loadEvent(webhookDB, eventID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var dlv *database.Delivery
|
||||
|
||||
err = webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||
var err error
|
||||
|
||||
dlv, err = createDelivery(tx, event.ID, target.ID, true)
|
||||
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
e.notify([]Task{buildTask(dlv, webhookID, &event, target, 1)})
|
||||
|
||||
return dlv.ID, nil
|
||||
}
|
||||
|
||||
// createDelivery writes a pending delivery of the event to the target
|
||||
// on the caller's transaction, and adds it to the target's totals.
|
||||
//
|
||||
// Neither Event nor Target is set on the row, and the per-webhook
|
||||
// database omits associations on every write in any case (see
|
||||
// internal/database/event_db_isolation.go), so the target's settings
|
||||
// are never copied into the event database.
|
||||
func createDelivery(
|
||||
tx *gorm.DB, eventID, targetID string, replay bool,
|
||||
) (*database.Delivery, error) {
|
||||
dlv := &database.Delivery{
|
||||
EventID: eventID,
|
||||
TargetID: targetID,
|
||||
Status: database.DeliveryStatusPending,
|
||||
Replay: replay,
|
||||
}
|
||||
|
||||
err := tx.Create(dlv).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"creating delivery for target %s: %w", targetID, err,
|
||||
)
|
||||
}
|
||||
|
||||
err = database.AddTargetTotals(tx, database.TargetTotals{
|
||||
TargetID: targetID, Deliveries: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return dlv, nil
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"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"
|
||||
)
|
||||
|
||||
// storeTestTarget creates an active http target of the setup's webhook
|
||||
// with every one of a target's settings set, so a task that drops one
|
||||
// of them differs from the target.
|
||||
func storeTestTarget(
|
||||
t *testing.T, s iSetup, name string,
|
||||
) database.Target {
|
||||
t.Helper()
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: s.WebhookID,
|
||||
Name: name,
|
||||
Type: database.TargetTypeHTTP,
|
||||
Active: true,
|
||||
URL: "http://93.184.216.34/" + name,
|
||||
Headers: map[string]string{"X-Api-Key": "k"},
|
||||
TimeoutSeconds: 7,
|
||||
ForwardQuery: true,
|
||||
ArchiveExpiry: time.Hour,
|
||||
ArchiveRotation: rotationDaily,
|
||||
MaxAttempts: 3,
|
||||
}
|
||||
|
||||
require.NoError(t, s.MainDB.Create(&target).Error)
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// newStoreTestEvent returns an event of the setup's webhook as the
|
||||
// receiver builds one, not yet stored.
|
||||
func newStoreTestEvent(s iSetup, body string) *database.Event {
|
||||
return &database.Event{
|
||||
WebhookID: s.WebhookID,
|
||||
EntrypointID: uuid.New().String(),
|
||||
Method: http.MethodPost,
|
||||
RawQuery: "a=1&b=2",
|
||||
Headers: `{"X-Test":["yes"]}`,
|
||||
ContentType: testContentType,
|
||||
Body: body,
|
||||
}
|
||||
}
|
||||
|
||||
// queuedTasks takes every task the engine has queued so far. The
|
||||
// engine in these tests is never started, so nothing else takes them.
|
||||
func queuedTasks(e *delivery.Engine) []delivery.Task {
|
||||
var tasks []delivery.Task
|
||||
|
||||
for {
|
||||
select {
|
||||
case task := <-e.ExportDeliveryCh():
|
||||
tasks = append(tasks, task)
|
||||
default:
|
||||
return tasks
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// assertTaskFor proves task carries delivery deliveryID of event to
|
||||
// target: every one of the target's settings and every event field.
|
||||
// They are copied into the task one field at a time, and a field left
|
||||
// out is sent empty without any error.
|
||||
func assertTaskFor(
|
||||
t *testing.T,
|
||||
task delivery.Task,
|
||||
deliveryID string,
|
||||
event *database.Event,
|
||||
target *database.Target,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
assert.Equal(t, deliveryID, task.DeliveryID)
|
||||
assert.Equal(t, event.ID, task.EventID)
|
||||
assert.Equal(t, event.WebhookID, task.WebhookID)
|
||||
assert.Equal(t, event.EntrypointID, task.EntrypointID)
|
||||
|
||||
assert.Equal(t, target.ID, task.TargetID)
|
||||
assert.Equal(t, target.Name, task.TargetName)
|
||||
assert.Equal(t, target.Type, task.TargetType)
|
||||
assert.Equal(t, target.URL, task.TargetURL)
|
||||
assert.Equal(t, target.Headers, task.TargetHeaders)
|
||||
assert.Equal(t, target.TimeoutSeconds, task.TimeoutSeconds)
|
||||
assert.Equal(t, target.ForwardQuery, task.ForwardQuery)
|
||||
assert.Equal(t, target.ArchiveExpiry, task.ArchiveExpiry)
|
||||
assert.Equal(t, target.ArchiveRotation, task.ArchiveRotation)
|
||||
assert.Equal(t, target.MaxAttempts, task.MaxAttempts)
|
||||
|
||||
assert.Equal(t, event.Method, task.Method)
|
||||
assert.Equal(t, event.RawQuery, task.RawQuery)
|
||||
assert.Equal(t, event.Headers, task.Headers)
|
||||
assert.Equal(t, event.ContentType, task.ContentType)
|
||||
require.NotNil(t, task.Body)
|
||||
assert.Equal(t, event.Body, *task.Body)
|
||||
|
||||
assert.Equal(t, 1, task.AttemptNum)
|
||||
}
|
||||
|
||||
// storedDeliveries reads every delivery of an event.
|
||||
func storedDeliveries(
|
||||
t *testing.T, db *gorm.DB, eventID string,
|
||||
) []database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
var deliveries []database.Delivery
|
||||
|
||||
require.NoError(t, db.Where("event_id = ?", eventID).
|
||||
Find(&deliveries).Error)
|
||||
|
||||
return deliveries
|
||||
}
|
||||
|
||||
// storedEventTotals reads the webhook's event totals.
|
||||
func storedEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
||||
t.Helper()
|
||||
|
||||
var totals database.EventTotals
|
||||
|
||||
require.NoError(t, db.First(&totals).Error)
|
||||
|
||||
return totals
|
||||
}
|
||||
|
||||
// countRows counts the rows of model in db.
|
||||
func countRows(t *testing.T, db *gorm.DB, model any) int64 {
|
||||
t.Helper()
|
||||
|
||||
var n int64
|
||||
|
||||
require.NoError(t, db.Model(model).Count(&n).Error)
|
||||
|
||||
return n
|
||||
}
|
||||
|
||||
// TestStoreEvent_StoresDeliveriesToActiveTargets proves StoreEvent
|
||||
// stores the event with one pending delivery to each active target and
|
||||
// none to an inactive one, adds them to the totals, and queues one task
|
||||
// per delivery.
|
||||
func TestStoreEvent_StoresDeliveriesToActiveTargets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
targets := []database.Target{
|
||||
storeTestTarget(t, s, "first"),
|
||||
storeTestTarget(t, s, "second"),
|
||||
}
|
||||
off := storeTestTarget(t, s, "off")
|
||||
|
||||
require.NoError(t, s.MainDB.Model(&database.Target{}).
|
||||
Where("id = ?", off.ID).Update("active", false).Error)
|
||||
|
||||
event := newStoreTestEvent(s, `{"hello":"world"}`)
|
||||
|
||||
created, err := s.Engine.StoreEvent(event)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, created)
|
||||
|
||||
var stored database.Event
|
||||
|
||||
require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error)
|
||||
assert.Equal(t, event.Body, stored.Body)
|
||||
assert.Equal(t, int64(len(event.Body)), stored.BodyBytes)
|
||||
|
||||
rows := storedDeliveries(t, s.WebhookDB, event.ID)
|
||||
queued := queuedTasks(s.Engine)
|
||||
|
||||
require.Len(t, rows, 2)
|
||||
require.Len(t, queued, 2)
|
||||
|
||||
deliveries := map[string]database.Delivery{}
|
||||
for _, dlv := range rows {
|
||||
deliveries[dlv.TargetID] = dlv
|
||||
}
|
||||
|
||||
tasks := map[string]delivery.Task{}
|
||||
for _, task := range queued {
|
||||
tasks[task.TargetID] = task
|
||||
}
|
||||
|
||||
for i := range targets {
|
||||
dlv, ok := deliveries[targets[i].ID]
|
||||
require.True(t, ok, "no delivery to %s", targets[i].Name)
|
||||
assert.Equal(t, database.DeliveryStatusPending, dlv.Status)
|
||||
assert.False(t, dlv.Replay)
|
||||
assert.Equal(t, int64(1),
|
||||
targetTotals(t, s.WebhookDB, targets[i].ID).Deliveries)
|
||||
|
||||
assertTaskFor(t, tasks[targets[i].ID], dlv.ID, event, &targets[i])
|
||||
}
|
||||
|
||||
totals := storedEventTotals(t, s.WebhookDB)
|
||||
assert.Equal(t, int64(1), totals.Events)
|
||||
require.NotNil(t, totals.LastEventAt)
|
||||
assert.True(t, totals.LastEventAt.Equal(stored.CreatedAt))
|
||||
|
||||
var entrypoint database.EntrypointTotals
|
||||
|
||||
require.NoError(t, s.WebhookDB.First(
|
||||
&entrypoint, "entrypoint_id = ?", event.EntrypointID,
|
||||
).Error)
|
||||
assert.True(t, entrypoint.LastEventAt.Equal(stored.CreatedAt))
|
||||
}
|
||||
|
||||
// TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint proves a
|
||||
// resubmitted copy is counted among the webhook's events but leaves its
|
||||
// entrypoint's last event as it is: it did not arrive on that URL.
|
||||
func TestStoreEvent_ResubmittedCopyLeavesTheEntrypoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
storeTestTarget(t, s, "only")
|
||||
|
||||
sourceID := uuid.New().String()
|
||||
event := newStoreTestEvent(s, `{"copy":true}`)
|
||||
event.ResubmittedFromID = &sourceID
|
||||
|
||||
created, err := s.Engine.StoreEvent(event)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, created)
|
||||
assert.Len(t, queuedTasks(s.Engine), 1)
|
||||
|
||||
assert.Equal(t, int64(1), storedEventTotals(t, s.WebhookDB).Events)
|
||||
assert.Zero(t, countRows(t, s.WebhookDB, &database.EntrypointTotals{}))
|
||||
}
|
||||
|
||||
// TestStoreEvent_NoActiveTarget proves an event of a webhook with no
|
||||
// active target is stored with no delivery and nothing queued.
|
||||
func TestStoreEvent_NoActiveTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
event := newStoreTestEvent(s, `{"to":"nobody"}`)
|
||||
|
||||
created, err := s.Engine.StoreEvent(event)
|
||||
require.NoError(t, err)
|
||||
assert.Zero(t, created)
|
||||
|
||||
assert.Equal(t, int64(1), countRows(t, s.WebhookDB, &database.Event{}))
|
||||
assert.Empty(t, storedDeliveries(t, s.WebhookDB, event.ID))
|
||||
assert.Empty(t, queuedTasks(s.Engine))
|
||||
}
|
||||
|
||||
// TestStoreEvent_LargeBodyIsNotCarried proves a body at the inline
|
||||
// limit is stored whole but left off the task, for the attempt to read
|
||||
// from the event row.
|
||||
func TestStoreEvent_LargeBodyIsNotCarried(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
storeTestTarget(t, s, "only")
|
||||
|
||||
event := newStoreTestEvent(
|
||||
s, strings.Repeat("x", delivery.MaxInlineBodySize),
|
||||
)
|
||||
|
||||
_, err := s.Engine.StoreEvent(event)
|
||||
require.NoError(t, err)
|
||||
|
||||
tasks := queuedTasks(s.Engine)
|
||||
require.Len(t, tasks, 1)
|
||||
assert.Nil(t, tasks[0].Body)
|
||||
|
||||
var stored database.Event
|
||||
|
||||
require.NoError(t, s.WebhookDB.First(&stored, "id = ?", event.ID).Error)
|
||||
assert.Equal(t, event.Body, stored.Body)
|
||||
}
|
||||
|
||||
// TestStoreEvent_RollsBackWhenAWriteFails proves the event, its
|
||||
// deliveries and its totals are one transaction: when the entrypoint
|
||||
// totals write fails, the last one, after the event, its delivery, the
|
||||
// target totals and the event totals were written, none of them is left
|
||||
// and nothing is queued.
|
||||
func TestStoreEvent_RollsBackWhenAWriteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
storeTestTarget(t, s, "only")
|
||||
|
||||
require.NoError(t,
|
||||
s.WebhookDB.Migrator().DropTable(&database.EntrypointTotals{}))
|
||||
|
||||
_, err := s.Engine.StoreEvent(newStoreTestEvent(s, `{"lost":true}`))
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Zero(t, countRows(t, s.WebhookDB, &database.Event{}))
|
||||
assert.Zero(t, countRows(t, s.WebhookDB, &database.Delivery{}))
|
||||
assert.Zero(t, countRows(t, s.WebhookDB, &database.TargetTotals{}))
|
||||
|
||||
totals := storedEventTotals(t, s.WebhookDB)
|
||||
assert.Zero(t, totals.Events)
|
||||
assert.Nil(t, totals.LastEventAt)
|
||||
|
||||
assert.Empty(t, queuedTasks(s.Engine))
|
||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||
}
|
||||
|
||||
// TestCreateReplay_CreatesAndQueuesAReplay proves CreateReplay adds a
|
||||
// new pending delivery marked as a replay, counts it in the target's
|
||||
// totals, queues its task built from the target it was given and the
|
||||
// stored event, and leaves the delivery it repeats as it was. The
|
||||
// stored event has every event field set, so a task that drops one of
|
||||
// them differs from the event.
|
||||
func TestCreateReplay_CreatesAndQueuesAReplay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
target := storeTestTarget(t, s, "replayed")
|
||||
event := newStoreTestEvent(s, `{"replay":"me"}`)
|
||||
require.NoError(t, s.WebhookDB.Create(event).Error)
|
||||
|
||||
original := iSeedDelivery(
|
||||
t, s.WebhookDB, event.ID, target.ID,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
|
||||
deliveryID, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target)
|
||||
require.NoError(t, err)
|
||||
|
||||
replay := storedDelivery(t, s.WebhookDB, deliveryID)
|
||||
assert.Equal(t, event.ID, replay.EventID)
|
||||
assert.Equal(t, target.ID, replay.TargetID)
|
||||
assert.Equal(t, database.DeliveryStatusPending, replay.Status)
|
||||
assert.True(t, replay.Replay)
|
||||
|
||||
assert.Equal(t, database.DeliveryStatusFailed,
|
||||
storedDelivery(t, s.WebhookDB, original.ID).Status)
|
||||
assert.Equal(t, int64(1),
|
||||
targetTotals(t, s.WebhookDB, target.ID).Deliveries)
|
||||
|
||||
tasks := queuedTasks(s.Engine)
|
||||
require.Len(t, tasks, 1)
|
||||
assertTaskFor(t, tasks[0], deliveryID, event, &target)
|
||||
}
|
||||
|
||||
// TestCreateReplay_RollsBackWhenAWriteFails proves the replay delivery
|
||||
// and its target totals are one transaction: when the totals write
|
||||
// fails, after the delivery was written, no new delivery is left and
|
||||
// nothing is queued.
|
||||
func TestCreateReplay_RollsBackWhenAWriteFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
target := storeTestTarget(t, s, "replayed")
|
||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"replay":"me"}`)
|
||||
iSeedDelivery(
|
||||
t, s.WebhookDB, event.ID, target.ID,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
|
||||
require.NoError(t,
|
||||
s.WebhookDB.Migrator().DropTable(&database.TargetTotals{}))
|
||||
|
||||
_, err := s.Engine.CreateReplay(s.WebhookID, event.ID, &target)
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Len(t, storedDeliveries(t, s.WebhookDB, event.ID), 1)
|
||||
assert.Empty(t, queuedTasks(s.Engine))
|
||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||
}
|
||||
|
||||
// storedDelivery reads one delivery back.
|
||||
func storedDelivery(
|
||||
t *testing.T, db *gorm.DB, deliveryID string,
|
||||
) database.Delivery {
|
||||
t.Helper()
|
||||
|
||||
var dlv database.Delivery
|
||||
|
||||
require.NoError(t, db.First(&dlv, "id = ?", deliveryID).Error)
|
||||
|
||||
return dlv
|
||||
}
|
||||
+12
-23
@@ -13,8 +13,8 @@ import (
|
||||
// The engine provides one to each target so a target can own
|
||||
// its retries durably: it records the attempt, marks the
|
||||
// delivery retrying, and asks the Scheduler to deliver the
|
||||
// next attempt after delay — exactly what the engine does for
|
||||
// its own restart recovery.
|
||||
// next attempt after delay — exactly what the engine's
|
||||
// recovery routine does for a retrying delivery.
|
||||
type Scheduler interface {
|
||||
ScheduleRetry(task Task, delay time.Duration)
|
||||
}
|
||||
@@ -32,7 +32,8 @@ type Scheduler interface {
|
||||
// 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.
|
||||
// from around this call. An attempt cut off by a stop is neither
|
||||
// counted nor recorded.
|
||||
type Target interface {
|
||||
Deliver(
|
||||
ctx context.Context,
|
||||
@@ -44,29 +45,20 @@ type Target interface {
|
||||
}
|
||||
|
||||
// rescheduler is implemented by targets that own durable
|
||||
// retries. The engine's restart recovery and periodic sweep
|
||||
// use it to let the target recompute the schedule for an
|
||||
// orphaned retrying delivery, keeping the retry schedule
|
||||
// target-owned. Fire-and-forget targets do not implement it
|
||||
// and their (never-occurring) retrying deliveries are
|
||||
// skipped.
|
||||
// retries. The engine's recovery routine uses it to let the
|
||||
// target recompute the schedule for an orphaned retrying
|
||||
// delivery, keeping the retry schedule target-owned.
|
||||
// Fire-and-forget targets do not implement it, and a retrying
|
||||
// delivery found for one is terminally failed.
|
||||
type rescheduler interface {
|
||||
// remainingBackoff returns how long to wait before the
|
||||
// next attempt of a recovered retrying delivery.
|
||||
// next attempt of a recovered retrying delivery: zero once
|
||||
// its backoff has passed.
|
||||
remainingBackoff(
|
||||
webhookDB *gorm.DB,
|
||||
deliveryID string,
|
||||
attemptNum int,
|
||||
) time.Duration
|
||||
|
||||
// backoffElapsed reports whether the backoff window for
|
||||
// the last attempt has already passed, so the periodic
|
||||
// sweep can re-enqueue the delivery now.
|
||||
backoffElapsed(
|
||||
webhookDB *gorm.DB,
|
||||
deliveryID string,
|
||||
attemptNum int,
|
||||
) bool
|
||||
}
|
||||
|
||||
// attemptResult is the outcome of a single delivery attempt,
|
||||
@@ -102,16 +94,13 @@ func (e *Engine) initTargets(client *http.Client) {
|
||||
client: client,
|
||||
}
|
||||
|
||||
dbT := &databaseTarget{eng: e}
|
||||
|
||||
e.httpTarget = httpT
|
||||
e.slackTarget = slackT
|
||||
e.dbTarget = dbT
|
||||
|
||||
e.targets = map[database.TargetType]Target{
|
||||
database.TargetTypeHTTP: httpT,
|
||||
database.TargetTypeSlack: slackT,
|
||||
database.TargetTypeDatabase: dbT,
|
||||
database.TargetTypeDatabase: &databaseTarget{eng: e},
|
||||
database.TargetTypeLog: &logTarget{eng: e},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -5,19 +5,21 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// configUnavailable is what a target's configuration renders
|
||||
// as when it is absent, of an unknown type, or does not
|
||||
// parse. The stored blob is never shown as a fallback: it can
|
||||
// hold a credential (a Slack incoming webhook URL is a bearer
|
||||
// token) and a UI that prints it leaks that credential into
|
||||
// browser history, screenshots and screen shares.
|
||||
// configUnavailable is what a target's settings render as when
|
||||
// the target is of an unknown type, and what its URL renders as
|
||||
// when it does not parse into a scheme and host. The stored URL is
|
||||
// never shown as a fallback: it can be a credential (a Slack
|
||||
// incoming webhook URL is a bearer token) and a UI that prints it
|
||||
// leaks that credential into browser history, screenshots and
|
||||
// screen shares.
|
||||
const configUnavailable = "(unavailable)"
|
||||
|
||||
// ConfigField is one labelled, display-safe value derived
|
||||
// from a target's stored configuration.
|
||||
// from a target's settings.
|
||||
type ConfigField struct {
|
||||
Label string
|
||||
Value string
|
||||
@@ -31,9 +33,9 @@ type ConfigField struct {
|
||||
const deletedNameSuffix = " (deleted)"
|
||||
|
||||
// TargetView is the display-safe projection of a target for
|
||||
// the UI. It deliberately has no raw configuration field, so
|
||||
// no template — present or future — can render the stored
|
||||
// blob.
|
||||
// the UI. It deliberately has no URL or header field, so no
|
||||
// template — present or future — can render the stored
|
||||
// credential.
|
||||
type TargetView struct {
|
||||
ID string
|
||||
Name string
|
||||
@@ -60,8 +62,7 @@ func (v TargetView) DisplayName() string {
|
||||
}
|
||||
|
||||
// NewTargetViews projects targets for rendering, replacing
|
||||
// each stored configuration blob with named, display-safe
|
||||
// fields.
|
||||
// each target's settings with named, display-safe 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
|
||||
@@ -90,8 +91,8 @@ func NewTargetViews(
|
||||
}
|
||||
|
||||
// targetConfigFields returns the display-safe fields for a
|
||||
// target's configuration. Anything it cannot parse becomes
|
||||
// the neutral placeholder.
|
||||
// target's settings. A target of an unknown type gets the
|
||||
// neutral placeholder.
|
||||
func targetConfigFields(
|
||||
t *database.Target,
|
||||
) []ConfigField {
|
||||
@@ -101,37 +102,26 @@ func targetConfigFields(
|
||||
case database.TargetTypeHTTP:
|
||||
return httpConfigFields(t)
|
||||
case database.TargetTypeDatabase:
|
||||
return databaseConfigFields(t.Config)
|
||||
return databaseConfigFields(t)
|
||||
case database.TargetTypeLog:
|
||||
// The log target takes no configuration.
|
||||
// The log target takes no settings.
|
||||
return nil
|
||||
default:
|
||||
return unavailableConfigFields()
|
||||
return []ConfigField{{
|
||||
Label: "Configuration",
|
||||
Value: configUnavailable,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// unavailableConfigFields is the neutral placeholder shown
|
||||
// for a configuration that could not be presented.
|
||||
func unavailableConfigFields() []ConfigField {
|
||||
return []ConfigField{{
|
||||
Label: "Configuration",
|
||||
Value: configUnavailable,
|
||||
}}
|
||||
}
|
||||
|
||||
// slackConfigFields describes a Slack target: its masked
|
||||
// webhook URL and its retry count. Only the masked URL is
|
||||
// shown; the full URL is the credential.
|
||||
// webhook URL and its number of delivery attempts. Only the
|
||||
// masked URL is shown; the full URL is the credential.
|
||||
func slackConfigFields(t *database.Target) []ConfigField {
|
||||
cfg, err := parseSlackConfig(t.Config)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
return []ConfigField{{
|
||||
Label: "Webhook URL",
|
||||
Value: cfg.MaskedWebhookURL(),
|
||||
}, maxRetriesField(t)}
|
||||
Value: MaskURL(t.URL),
|
||||
}, maxAttemptsField(t)}
|
||||
}
|
||||
|
||||
// httpConfigFields describes an HTTP target: its destination
|
||||
@@ -145,72 +135,55 @@ func slackConfigFields(t *database.Target) []ConfigField {
|
||||
// whose path segments are the credential, and the field takes
|
||||
// an arbitrary URL, so no segment can be assumed non-secret.
|
||||
func httpConfigFields(t *database.Target) []ConfigField {
|
||||
cfg, err := parseHTTPConfig(t.Config)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
fields := []ConfigField{{
|
||||
Label: "Destination URL",
|
||||
Value: MaskURL(cfg.URL),
|
||||
Value: MaskURL(t.URL),
|
||||
}}
|
||||
|
||||
if cfg.Timeout > 0 {
|
||||
if t.TimeoutSeconds > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Timeout",
|
||||
Value: strconv.Itoa(cfg.Timeout) + "s",
|
||||
Value: strconv.Itoa(t.TimeoutSeconds) + "s",
|
||||
})
|
||||
}
|
||||
|
||||
if len(cfg.Headers) > 0 {
|
||||
if len(t.Headers) > 0 {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Headers",
|
||||
Value: fmt.Sprintf(
|
||||
"%d configured", len(cfg.Headers),
|
||||
"%d configured", len(t.Headers),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
fields = append(fields, maxRetriesField(t))
|
||||
if t.ForwardQuery {
|
||||
fields = append(fields, ConfigField{
|
||||
Label: "Query string",
|
||||
Value: "passed on to this target",
|
||||
})
|
||||
}
|
||||
|
||||
fields = append(fields, maxAttemptsField(t))
|
||||
|
||||
return fields
|
||||
}
|
||||
|
||||
// maxRetriesField describes a target's retry count, which lives
|
||||
// on the target row rather than in its configuration blob. A
|
||||
// stored 0 makes a single attempt, so it is shown as 1.
|
||||
func maxRetriesField(t *database.Target) ConfigField {
|
||||
attempts := strconv.Itoa(t.MaxRetries)
|
||||
if t.MaxRetries == 0 {
|
||||
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
|
||||
}
|
||||
|
||||
// maxAttemptsField describes a target's number of delivery
|
||||
// attempts.
|
||||
func maxAttemptsField(t *database.Target) ConfigField {
|
||||
return ConfigField{
|
||||
Label: "Delivery attempts",
|
||||
Value: attempts,
|
||||
Value: strconv.Itoa(t.MaxAttempts),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
expiry, err := parseArchiveExpiry(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
rotation, err := parseArchiveRotation(configJSON)
|
||||
if err != nil {
|
||||
return unavailableConfigFields()
|
||||
}
|
||||
|
||||
value := archiveExpiryNever
|
||||
if expiry > 0 {
|
||||
value = plainDuration(expiry)
|
||||
// the archive is kept forever, and by its rotation.
|
||||
func databaseConfigFields(t *database.Target) []ConfigField {
|
||||
value := archive.ExpiryNever
|
||||
if t.ArchiveExpiry > 0 {
|
||||
value = plainDuration(t.ArchiveExpiry)
|
||||
}
|
||||
|
||||
return []ConfigField{{
|
||||
@@ -218,7 +191,7 @@ func databaseConfigFields(configJSON string) []ConfigField {
|
||||
Value: value,
|
||||
}, {
|
||||
Label: "Archive rotation",
|
||||
Value: rotation,
|
||||
Value: t.ArchiveRotation,
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -254,14 +227,3 @@ func plainDuration(d time.Duration) string {
|
||||
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
||||
// its scheme and host, with the path, query and any userinfo
|
||||
// elided. The path segments are the credential, so none of
|
||||
// them is shown: the field accepts an arbitrary URL, so no
|
||||
// segment can be assumed non-secret. A URL that does not
|
||||
// parse into a scheme and host yields the neutral
|
||||
// placeholder, never the raw string.
|
||||
func (c *SlackTargetConfig) MaskedWebhookURL() string {
|
||||
return MaskURL(c.WebhookURL)
|
||||
}
|
||||
|
||||
@@ -32,10 +32,20 @@ const (
|
||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||
viewUnavailable = "(unavailable)"
|
||||
viewExpiryNever = "never"
|
||||
viewMaxRetries = "Delivery attempts"
|
||||
viewMaxAttempts = "Delivery attempts"
|
||||
)
|
||||
|
||||
func TestMaskedWebhookURL(t *testing.T) {
|
||||
// The archive rotations a database target's settings can name.
|
||||
const (
|
||||
rotationNone = "none"
|
||||
rotationMonthly = "monthly"
|
||||
rotationDaily = "daily"
|
||||
rotationHourly = "hourly"
|
||||
)
|
||||
|
||||
// TestNewTargetViews_SlackMasksWebhookURL shows what a Slack target's
|
||||
// URL renders as on the target list.
|
||||
func TestNewTargetViews_SlackMasksWebhookURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]struct {
|
||||
@@ -79,28 +89,24 @@ func TestMaskedWebhookURL(t *testing.T) {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &delivery.SlackTargetConfig{
|
||||
WebhookURL: tc.url,
|
||||
}
|
||||
target := slackTarget()
|
||||
target.URL = tc.url
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want, cfg.MaskedWebhookURL(),
|
||||
t, tc.want,
|
||||
fieldMap(viewFor(t, target).Config)["Webhook URL"],
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMaskedWebhookURL_NeverLeaksPath is the direct
|
||||
// TestNewTargetViews_SlackNeverLeaksPath is the direct
|
||||
// expression of the rule: whatever the input, the masked
|
||||
// value never contains a path segment of it.
|
||||
func TestMaskedWebhookURL_NeverLeaksPath(t *testing.T) {
|
||||
func TestNewTargetViews_SlackNeverLeaksPath(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &delivery.SlackTargetConfig{
|
||||
WebhookURL: slackWebhookURL,
|
||||
}
|
||||
|
||||
masked := cfg.MaskedWebhookURL()
|
||||
masked := fieldMap(viewFor(t, slackTarget()).Config)["Webhook URL"]
|
||||
|
||||
assert.NotContains(t, masked, "T00000000")
|
||||
assert.NotContains(t, masked, "B00000000")
|
||||
@@ -166,11 +172,11 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
||||
// share.
|
||||
func slackTarget() database.Target {
|
||||
return database.Target{
|
||||
Name: slackTargetName,
|
||||
Type: database.TargetTypeSlack,
|
||||
Active: true,
|
||||
Config: `{"webhookUrl":"` +
|
||||
slackWebhookURL + `"}`,
|
||||
Name: slackTargetName,
|
||||
Type: database.TargetTypeSlack,
|
||||
Active: true,
|
||||
URL: slackWebhookURL,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,28 +195,29 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Webhook URL": slackMaskedURL,
|
||||
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||
"Webhook URL": slackMaskedURL,
|
||||
viewMaxAttempts: "1",
|
||||
},
|
||||
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) {
|
||||
// TestNewTargetViews_SlackAttempts proves a Slack target shows
|
||||
// its number of delivery attempts the same way an HTTP target
|
||||
// does.
|
||||
func TestNewTargetViews_SlackAttempts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
target := slackTarget()
|
||||
target.MaxRetries = 2
|
||||
target.MaxAttempts = 2
|
||||
|
||||
view := viewFor(t, target)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Webhook URL": slackMaskedURL,
|
||||
viewMaxRetries: "2",
|
||||
"Webhook URL": slackMaskedURL,
|
||||
viewMaxAttempts: "2",
|
||||
},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
@@ -220,11 +227,12 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"` + viewExampleHook + `",` +
|
||||
`"timeout":30,` +
|
||||
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
||||
MaxRetries: 5,
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: viewExampleHook,
|
||||
TimeoutSeconds: 30,
|
||||
Headers: map[string]string{headerAuthorization: "Bearer sekrit"},
|
||||
ForwardQuery: true,
|
||||
MaxAttempts: 5,
|
||||
})
|
||||
|
||||
fields := fieldMap(view.Config)
|
||||
@@ -235,7 +243,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
||||
"Destination URL": viewMaskedOrigin,
|
||||
"Timeout": "30s",
|
||||
"Headers": "1 configured",
|
||||
viewMaxRetries: "5",
|
||||
"Query string": "passed on to this target",
|
||||
viewMaxAttempts: "5",
|
||||
},
|
||||
fields,
|
||||
)
|
||||
@@ -246,19 +255,22 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
||||
// TestNewTargetViews_HTTPSingleAttempt proves a single attempt is
|
||||
// shown as the plain number, like any other.
|
||||
func TestNewTargetViews_HTTPSingleAttempt(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"` + viewExampleHook + `"}`,
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: viewExampleHook,
|
||||
MaxAttempts: 1,
|
||||
})
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Destination URL": viewMaskedOrigin,
|
||||
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||
viewMaxAttempts: "1",
|
||||
},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
@@ -272,8 +284,9 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"` + slackWebhookURL + `"}`,
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: slackWebhookURL,
|
||||
MaxAttempts: 1,
|
||||
})
|
||||
|
||||
fields := fieldMap(view.Config)
|
||||
@@ -296,25 +309,20 @@ func TestNewTargetViews_Database(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := map[string]struct {
|
||||
config string
|
||||
expiry time.Duration
|
||||
want string
|
||||
}{
|
||||
"empty config": {config: "", want: viewExpiryNever},
|
||||
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
||||
"never literal": {
|
||||
config: `{"expiry":"` + 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"},
|
||||
"never": {expiry: 0, want: viewExpiryNever},
|
||||
"1h": {expiry: time.Hour, want: "1 hour"},
|
||||
"12h": {expiry: 12 * time.Hour, want: "12 hours"},
|
||||
"24h": {expiry: 24 * time.Hour, want: "1 day"},
|
||||
"720h": {expiry: 720 * time.Hour, want: "30 days"},
|
||||
"2160h": {expiry: 2160 * time.Hour, want: "90 days"},
|
||||
"8760h": {expiry: 8760 * time.Hour, want: "365 days"},
|
||||
"36h": {expiry: 36 * time.Hour, want: "36 hours"},
|
||||
"1h30m": {expiry: 90 * time.Minute, want: "90 minutes"},
|
||||
"45s": {expiry: 45 * time.Second, want: "45 seconds"},
|
||||
"1.5s": {expiry: 1500 * time.Millisecond, want: "1.5s"},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
@@ -322,8 +330,9 @@ func TestNewTargetViews_Database(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: tc.config,
|
||||
Type: database.TargetTypeDatabase,
|
||||
ArchiveExpiry: tc.expiry,
|
||||
ArchiveRotation: rotationNone,
|
||||
})
|
||||
|
||||
assert.Equal(
|
||||
@@ -339,33 +348,23 @@ func TestNewTargetViews_Database(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNewTargetViews_DatabaseRotation proves the target list shows a
|
||||
// database target's rotation, none when it has none stored.
|
||||
// database target's rotation.
|
||||
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.Run(rotation, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: config,
|
||||
Type: database.TargetTypeDatabase,
|
||||
ArchiveRotation: rotation,
|
||||
})
|
||||
|
||||
assert.Equal(
|
||||
t, want, fieldMap(view.Config)["Archive rotation"],
|
||||
t, rotation, fieldMap(view.Config)["Archive rotation"],
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -375,68 +374,28 @@ func TestNewTargetViews_Log(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetTypeLog,
|
||||
Config: "",
|
||||
Type: database.TargetTypeLog,
|
||||
})
|
||||
|
||||
assert.Empty(t, view.Config)
|
||||
}
|
||||
|
||||
// TestNewTargetViews_Unpresentable proves that no config the
|
||||
// view cannot present falls back to the stored blob.
|
||||
func TestNewTargetViews_Unpresentable(t *testing.T) {
|
||||
// TestNewTargetViews_UnknownType proves that a target of a type the
|
||||
// view does not know shows the neutral placeholder, and never its
|
||||
// URL.
|
||||
func TestNewTargetViews_UnknownType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const blob = `{"webhookUrl":"https://hooks.slack.com` +
|
||||
slackSecretPath + `"`
|
||||
view := viewFor(t, database.Target{
|
||||
Type: database.TargetType("carrier-pigeon"),
|
||||
URL: slackWebhookURL,
|
||||
})
|
||||
|
||||
tests := map[string]database.Target{
|
||||
"unknown target type": {
|
||||
Type: database.TargetType("carrier-pigeon"),
|
||||
Config: blob,
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Configuration": viewUnavailable,
|
||||
},
|
||||
"unparseable json": {
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: blob,
|
||||
},
|
||||
"empty slack config": {
|
||||
Type: database.TargetTypeSlack,
|
||||
},
|
||||
"slack config without url": {
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{}`,
|
||||
},
|
||||
"unparseable http json": {
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":`,
|
||||
},
|
||||
"unparseable archive json": {
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":`,
|
||||
},
|
||||
"invalid archive expiry": {
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"a fortnight"}`,
|
||||
},
|
||||
"invalid archive rotation": {
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: weeklyConfig,
|
||||
},
|
||||
}
|
||||
|
||||
for name, target := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
view := viewFor(t, target)
|
||||
|
||||
assert.Equal(
|
||||
t,
|
||||
map[string]string{
|
||||
"Configuration": viewUnavailable,
|
||||
},
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
})
|
||||
}
|
||||
fieldMap(view.Config),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2,83 +2,23 @@ package delivery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// archiveNameMaxLen is how many characters of a webhook or target
|
||||
// name an archive file name keeps.
|
||||
const archiveNameMaxLen = 40
|
||||
|
||||
// databaseTarget is a no-retry target that archives the full
|
||||
// inbound event into the target's own archive SQLite file, separate
|
||||
// from the per-webhook event database. The event is already
|
||||
// persisted in the per-webhook event DB by the time delivery runs;
|
||||
// the database target additionally writes a durable long-term copy
|
||||
// into the file ArchiveFileName names, with a period added when the
|
||||
// target rotates (see archivePeriodPath), and then records a single
|
||||
// through the engine's archive.Writers, 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.
|
||||
// succeeded. Package archive holds everything about the files
|
||||
// themselves.
|
||||
type databaseTarget struct {
|
||||
eng *Engine
|
||||
|
||||
// writers holds one archive writer per database target, keyed
|
||||
// by target ID.
|
||||
mu sync.Mutex
|
||||
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
|
||||
@@ -98,7 +38,7 @@ func (t *databaseTarget) Deliver(
|
||||
) {
|
||||
start := time.Now()
|
||||
|
||||
err := t.archive(d)
|
||||
err := t.eng.archives.Write(d)
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
@@ -145,307 +85,3 @@ func (t *databaseTarget) Deliver(
|
||||
database.DeliveryStatusDelivered,
|
||||
)
|
||||
}
|
||||
|
||||
// archive writes the full event as a row into the target's
|
||||
// archive database, honouring the optional per-target expiry and
|
||||
// rotation parsed from the target config JSON. With rotation, the
|
||||
// event goes to the file for the period of its receive time.
|
||||
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||
webhookID := d.Event.WebhookID
|
||||
if webhookID == "" {
|
||||
return errArchiveMissingWebhookID
|
||||
}
|
||||
|
||||
expiry, err := parseArchiveExpiry(d.Target.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rotation, err := parseArchiveRotation(d.Target.Config)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
row := archivedEvent{
|
||||
EventID: d.Event.ID,
|
||||
WebhookID: webhookID,
|
||||
EntrypointID: d.Event.EntrypointID,
|
||||
Method: d.Event.Method,
|
||||
Headers: d.Event.Headers,
|
||||
Body: d.Event.Body,
|
||||
ContentType: d.Event.ContentType,
|
||||
}
|
||||
|
||||
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
|
||||
}
|
||||
|
||||
// writerFor returns the archive writer for a database target,
|
||||
// creating and caching it on first use. Each target has one writer
|
||||
// so its close/reopen debounce state is shared across concurrent
|
||||
// deliveries, and so a rename and the idle sweep take the same lock
|
||||
// as its writes.
|
||||
func (t *databaseTarget) writerFor(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
w, ok := t.writers[targetID]
|
||||
if !ok {
|
||||
var err error
|
||||
|
||||
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
|
||||
// it moments ago, it now belongs to the registry proper and
|
||||
// the sweep must leave it in place when it finishes.
|
||||
w.sweepOwned = false
|
||||
|
||||
return w, nil
|
||||
}
|
||||
|
||||
// sweepWriterFor returns the archive writer the idle sweep should
|
||||
// prune a target's archive through, together with whether the sweep
|
||||
// itself created the registry entry.
|
||||
//
|
||||
// The sweep must route its prune through the registered writer so
|
||||
// the writer's mutex orders it against concurrent writes, but it
|
||||
// must never leave a registry entry behind: a sweep that ran
|
||||
// concurrently with the target's deletion would otherwise
|
||||
// re-create an entry that nothing will ever evict again, which is
|
||||
// exactly the leak eviction exists to prevent. An entry the sweep
|
||||
// creates is therefore marked sweep-owned and handed back to
|
||||
// releaseSweepWriter when the sweep is done.
|
||||
func (t *databaseTarget) sweepWriterFor(
|
||||
targetID string,
|
||||
) (*archiveWriter, bool, error) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
w, ok := t.writers[targetID]
|
||||
if ok {
|
||||
return w, false, nil
|
||||
}
|
||||
|
||||
w, err := t.newWriter(targetID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
if t.writers == nil {
|
||||
t.writers = make(map[string]*archiveWriter)
|
||||
}
|
||||
|
||||
w.sweepOwned = true
|
||||
t.writers[targetID] = w
|
||||
|
||||
return w, true, nil
|
||||
}
|
||||
|
||||
// releaseSweepWriter drops a registry entry that the idle sweep
|
||||
// created, so a sweep leaves the registry exactly as it found it.
|
||||
//
|
||||
// The entry is removed only if it is still the very writer the
|
||||
// sweep installed and no delivery has claimed it in the meantime
|
||||
// (writerFor clears sweepOwned when it hands a writer to the
|
||||
// write path). Both conditions are evaluated under the registry
|
||||
// lock, so an eviction that raced the sweep — which removes the
|
||||
// entry outright — simply finds nothing left to do here, and a
|
||||
// delivery that adopted the writer keeps a registered, evictable
|
||||
// one.
|
||||
func (t *databaseTarget) releaseSweepWriter(
|
||||
targetID string, w *archiveWriter,
|
||||
) {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
cur, ok := t.writers[targetID]
|
||||
if !ok || cur != w || !cur.sweepOwned {
|
||||
return
|
||||
}
|
||||
|
||||
delete(t.writers, targetID)
|
||||
}
|
||||
|
||||
// newWriter builds the writer for a database target's archive. Its
|
||||
// path is the one ArchivePath gives for the webhook and the target as
|
||||
// the main database names them now; from then on only rename changes
|
||||
// the name the writer uses. It does not touch the archive files.
|
||||
func (t *databaseTarget) newWriter(
|
||||
targetID string,
|
||||
) (*archiveWriter, error) {
|
||||
if t.eng.dbManager == nil {
|
||||
return nil, errArchiveNoDataDir
|
||||
}
|
||||
|
||||
var target database.Target
|
||||
|
||||
err := t.eng.database.DB().
|
||||
Preload("Webhook").
|
||||
First(&target, "id = ?", targetID).Error
|
||||
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
|
||||
// name for webhookName and targetName. It goes through the target's
|
||||
// writer, so the move holds the lock that writes and the idle sweep
|
||||
// take, and later writes use the new name.
|
||||
//
|
||||
// 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
|
||||
// then released before the handle is closed under the writer's
|
||||
// own lock: that ordering keeps the registry available to other
|
||||
// targets while an in-flight write on this one drains, and
|
||||
// closing under the writer's lock means eviction can never race
|
||||
// a write.
|
||||
//
|
||||
// Eviction is idempotent and silent for a target with no writer,
|
||||
// which is the common case: only a database target that has
|
||||
// received an event or been renamed has one. It never deletes the
|
||||
// archive file.
|
||||
func (t *databaseTarget) evict(targetID string) {
|
||||
t.mu.Lock()
|
||||
|
||||
w, ok := t.writers[targetID]
|
||||
if ok {
|
||||
delete(t.writers, targetID)
|
||||
}
|
||||
|
||||
t.mu.Unlock()
|
||||
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
w.evict()
|
||||
|
||||
t.eng.log.Info(
|
||||
"evicted archive writer",
|
||||
"target_id", targetID,
|
||||
"path", w.path,
|
||||
)
|
||||
}
|
||||
|
||||
// evictWebhook evicts, exactly as evict does, the writer of every
|
||||
// database target of a webhook.
|
||||
func (t *databaseTarget) evictWebhook(webhookID string) {
|
||||
t.mu.Lock()
|
||||
|
||||
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
|
||||
// to create to reach the writer's mutex is released again once
|
||||
// the prune is done, so a sweep racing a target deletion cannot
|
||||
// resurrect the writer the eviction just dropped.
|
||||
func (t *databaseTarget) sweepArchive(
|
||||
targetID string, expiry time.Duration,
|
||||
) error {
|
||||
w, created, err := t.sweepWriterFor(targetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if created {
|
||||
defer t.releaseSweepWriter(targetID, w)
|
||||
}
|
||||
|
||||
return w.sweepExpired(expiry)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,69 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||
"sneak.berlin/go/webhooker/internal/archive"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||
"sneak.berlin/go/webhooker/internal/db/migrations"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||
)
|
||||
|
||||
// archiveTestWebhookName is the name of every webhook
|
||||
// seedDatabaseTarget creates. It is not safe in a file name as it
|
||||
// stands, so every archive file name made from it is made safe first.
|
||||
const archiveTestWebhookName = "Sweep Test!"
|
||||
|
||||
// archiveEnv bundles the pieces a database target test drives: a main
|
||||
// configuration database holding webhooks and targets, a delivery
|
||||
// engine whose archive writers write into the data directory, and that
|
||||
// directory.
|
||||
type archiveEnv struct {
|
||||
eng *delivery.Engine
|
||||
mainDB *database.Database
|
||||
dataDir string
|
||||
}
|
||||
|
||||
func setupArchiveTest(t *testing.T) *archiveEnv {
|
||||
t.Helper()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
|
||||
mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler))
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = mainDB.Close() })
|
||||
|
||||
return &archiveEnv{
|
||||
eng: delivery.NewTestEngineWithDB(
|
||||
mainDB,
|
||||
databasetest.NewWebhookDBManager(t, dataDir),
|
||||
archiveTestLogger(),
|
||||
&http.Client{Timeout: 5 * time.Second},
|
||||
1,
|
||||
),
|
||||
mainDB: mainDB,
|
||||
dataDir: dataDir,
|
||||
}
|
||||
}
|
||||
|
||||
func archiveTestLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(
|
||||
os.Stderr,
|
||||
@@ -28,309 +71,50 @@ func archiveTestLogger() *slog.Logger {
|
||||
))
|
||||
}
|
||||
|
||||
// openArchiveDBForRead opens an archive file read-only so a
|
||||
// test can inspect the rows the writer persisted.
|
||||
func openArchiveDBForRead(
|
||||
t *testing.T, path string,
|
||||
) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite",
|
||||
fmt.Sprintf("file:%s?mode=ro", path),
|
||||
// archivePath returns where the engine keeps a database target's
|
||||
// archive file, for the names seedDatabaseTarget gave it.
|
||||
func (env *archiveEnv) archivePath(tgt *database.Target) string {
|
||||
return filepath.Join(
|
||||
env.dataDir,
|
||||
archive.FileName(archiveTestWebhookName, tgt.Name, tgt.ID),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return gdb
|
||||
}
|
||||
|
||||
// archiveFileSuffixes returns the archive file itself and the
|
||||
// SQLite sidecars that accompany an open database. A test that
|
||||
// asserts no archive was created has to check all of them.
|
||||
func archiveFileSuffixes() []string {
|
||||
return []string{"", "-wal", "-shm"}
|
||||
}
|
||||
|
||||
// removeArchiveFiles simulates an operator moving the archive
|
||||
// away by deleting the SQLite file and its sidecar files.
|
||||
func removeArchiveFiles(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
|
||||
for _, suffix := range []string{
|
||||
"", "-wal", "-shm", "-journal",
|
||||
} {
|
||||
err := os.Remove(path + suffix)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
t.Fatalf("removing %s%s: %v", path, suffix, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
||||
// a database target marks the delivery delivered and archives
|
||||
// the full event into the target's own archive file.
|
||||
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
require.NoError(t, webhookDB.First(
|
||||
&updated, "id = ?", d.ID,
|
||||
).Error)
|
||||
assert.Equal(t,
|
||||
database.DeliveryStatusDelivered, updated.Status,
|
||||
"database target should mark the delivery delivered",
|
||||
)
|
||||
|
||||
archivePath := filepath.Join(
|
||||
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
|
||||
)
|
||||
assert.FileExists(t, archivePath)
|
||||
|
||||
rdb := openArchiveDBForRead(t, archivePath)
|
||||
|
||||
var rows []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, rdb.Find(&rows).Error)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, event.ID, rows[0].EventID)
|
||||
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
||||
assert.Equal(t, event.Method, rows[0].Method)
|
||||
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_WritesRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
row := delivery.ExportArchivedEvent{
|
||||
EventID: "ev-1",
|
||||
WebhookID: "wh-1",
|
||||
EntrypointID: "ep-1",
|
||||
Method: "POST",
|
||||
Headers: `{"X":"Y"}`,
|
||||
Body: `{"hello":"world"}`,
|
||||
ContentType: "application/json",
|
||||
}
|
||||
|
||||
require.NoError(t, w.Write(row, 0))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
var got []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "ev-1", got[0].EventID)
|
||||
assert.Equal(t, "wh-1", got[0].WebhookID)
|
||||
assert.Equal(t, "ep-1", got[0].EntrypointID)
|
||||
assert.Equal(t, row.Method, got[0].Method)
|
||||
assert.Equal(t, row.ContentType, got[0].ContentType)
|
||||
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
|
||||
assert.False(t, got[0].ArchivedAt.IsZero())
|
||||
}
|
||||
|
||||
func TestArchiveWriter_RecreatesAfterRemoval(
|
||||
// seedDatabaseTarget creates a webhook with one database target,
|
||||
// which keeps its archive forever in one file, and returns the
|
||||
// target.
|
||||
func (env *archiveEnv) seedDatabaseTarget(
|
||||
t *testing.T,
|
||||
) {
|
||||
t.Parallel()
|
||||
) *database.Target {
|
||||
t.Helper()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
wh := &database.Webhook{
|
||||
UserID: uuid.New().String(),
|
||||
Name: archiveTestWebhookName,
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
env.mainDB.DB().
|
||||
Omit(clause.Associations).
|
||||
Create(wh).Error,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
// The operator moves the archive away while the handle is
|
||||
// still open.
|
||||
removeArchiveFiles(t, path)
|
||||
require.NoFileExists(t, path)
|
||||
|
||||
// The next write recreates the file with a fresh schema and
|
||||
// only the new row.
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
||||
))
|
||||
assert.FileExists(t, path)
|
||||
|
||||
var got []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "b", got[0].EventID)
|
||||
}
|
||||
|
||||
// TestStatArchive proves StatArchive finds no file before the first
|
||||
// write; after a write still held in the -wal, counts the -wal in the
|
||||
// size and takes its later time as the last write; and finds no file
|
||||
// again once the file has been moved away.
|
||||
func TestStatArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
|
||||
_, err := delivery.StatArchive(path)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
|
||||
// With the clock stopped, the reopen debounce never passes, so
|
||||
// the handle stays open after the write.
|
||||
stopped := time.Now()
|
||||
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||
w.SetNow(func() time.Time { return stopped })
|
||||
|
||||
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
|
||||
|
||||
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
earlier := written.Add(-time.Hour)
|
||||
require.NoError(t, os.Chtimes(path, earlier, earlier))
|
||||
require.NoError(t, os.Chtimes(path+"-wal", written, written))
|
||||
|
||||
file, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
wal, err := os.Stat(path + "-wal")
|
||||
require.NoError(t, err)
|
||||
require.Positive(t, wal.Size())
|
||||
|
||||
got, err := delivery.StatArchive(path)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 1, got.Files)
|
||||
assert.Equal(t, file.Size()+wal.Size(), got.Size)
|
||||
assert.True(t, written.Equal(got.Written), got.Written)
|
||||
|
||||
removeArchiveFiles(t, path)
|
||||
|
||||
_, err = delivery.StatArchive(path)
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const debounce = 2 * time.Second
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), debounce,
|
||||
tgt := &database.Target{
|
||||
WebhookID: wh.ID,
|
||||
Name: "Archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Active: true,
|
||||
ArchiveRotation: "none",
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
require.NoError(
|
||||
t,
|
||||
env.mainDB.DB().
|
||||
Omit(clause.Associations).
|
||||
Create(tgt).Error,
|
||||
)
|
||||
|
||||
// The writer measures its reopen debounce on this clock, which
|
||||
// only the test moves, so how long the host takes between
|
||||
// writes cannot change the result.
|
||||
now := time.Now()
|
||||
|
||||
w.SetNow(func() time.Time { return now })
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "a"}, 0,
|
||||
))
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "b"}, 0,
|
||||
))
|
||||
|
||||
// Two writes inside the debounce window trigger only the
|
||||
// initial open — no extra close/reopen.
|
||||
assert.Equal(t, 1, w.Reopens())
|
||||
|
||||
now = now.Add(debounce)
|
||||
|
||||
require.NoError(t, w.Write(
|
||||
delivery.ExportArchivedEvent{EventID: "c"}, 0,
|
||||
))
|
||||
|
||||
// A write after the window elapses closes and reopens once.
|
||||
assert.Equal(t, 2, w.Reopens())
|
||||
}
|
||||
|
||||
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
path, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Open(0))
|
||||
|
||||
old := delivery.ExportArchivedEvent{
|
||||
EventID: "old",
|
||||
ArchivedAt: time.Now().Add(-2 * time.Hour),
|
||||
}
|
||||
fresh := delivery.ExportArchivedEvent{
|
||||
EventID: "fresh",
|
||||
ArchivedAt: time.Now(),
|
||||
}
|
||||
|
||||
require.NoError(t, w.DB().Create(&old).Error)
|
||||
require.NoError(t, w.DB().Create(&fresh).Error)
|
||||
|
||||
// Reopening with a one-hour expiry prunes the old row.
|
||||
require.NoError(t, w.Reopen(time.Hour))
|
||||
|
||||
var got []delivery.ExportArchivedEvent
|
||||
|
||||
require.NoError(t, w.DB().Find(&got).Error)
|
||||
require.Len(t, got, 1)
|
||||
assert.Equal(t, "fresh", got[0].EventID)
|
||||
}
|
||||
|
||||
func TestParseArchiveExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want time.Duration
|
||||
wantErr bool
|
||||
}{
|
||||
{"empty config", "", 0, false},
|
||||
{"explicit never", `{"expiry":"never"}`, 0, false},
|
||||
{"empty expiry", `{"expiry":""}`, 0, false},
|
||||
{"duration", `{"expiry":"1h"}`, time.Hour, false},
|
||||
{"unparseable", `{"expiry":"nonsense"}`, 0, true},
|
||||
{"zero duration", `{"expiry":"0s"}`, 0, true},
|
||||
{"negative duration", `{"expiry":"-5h"}`, 0, true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := delivery.ExportParseArchiveExpiry(tc.in)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
return tgt
|
||||
}
|
||||
|
||||
// seedDatabaseTargetDelivery seeds a pending delivery of an event
|
||||
@@ -355,9 +139,10 @@ func seedDatabaseTargetDelivery(
|
||||
Status: database.DeliveryStatusPending,
|
||||
Event: event,
|
||||
Target: database.Target{
|
||||
Name: tgt.Name,
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: tgt.Config,
|
||||
Name: tgt.Name,
|
||||
Type: database.TargetTypeDatabase,
|
||||
ArchiveExpiry: tgt.ArchiveExpiry,
|
||||
ArchiveRotation: tgt.ArchiveRotation,
|
||||
},
|
||||
}
|
||||
d.ID = dlv.ID
|
||||
@@ -365,10 +150,119 @@ func seedDatabaseTargetDelivery(
|
||||
return d
|
||||
}
|
||||
|
||||
// openArchiveDBForRead opens an archive file read-only so a
|
||||
// test can inspect the rows the database target archived.
|
||||
func openArchiveDBForRead(
|
||||
t *testing.T, path string,
|
||||
) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite",
|
||||
fmt.Sprintf("file:%s?mode=ro", path),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
return gdb
|
||||
}
|
||||
|
||||
// countArchivedRows counts the rows in an archive file without
|
||||
// asserting anything, so it is safe to poll from an
|
||||
// assert.Eventually condition (which runs off the test
|
||||
// goroutine, where testify assertions must not be used).
|
||||
func countArchivedRows(path string) (int64, error) {
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite", fmt.Sprintf("file:%s?mode=ro", path),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var count int64
|
||||
|
||||
err = gdb.Table("archived_events").Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// archivedRow is the columns of an archive row these tests read.
|
||||
type archivedRow struct {
|
||||
EventID string
|
||||
WebhookID string
|
||||
Method string
|
||||
RawQuery string
|
||||
Body string
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_ArchivesEvent verifies that delivering to
|
||||
// a database target marks the delivery delivered and archives
|
||||
// the full event into the target's own archive file.
|
||||
func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||
event.RawQuery = eventQuery
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
var updated database.Delivery
|
||||
|
||||
require.NoError(t, webhookDB.First(
|
||||
&updated, "id = ?", d.ID,
|
||||
).Error)
|
||||
assert.Equal(t,
|
||||
database.DeliveryStatusDelivered, updated.Status,
|
||||
"database target should mark the delivery delivered",
|
||||
)
|
||||
|
||||
archivePath := filepath.Join(
|
||||
env.dataDir, "archive-sweep-test-archive-"+tgt.ID+".db",
|
||||
)
|
||||
assert.FileExists(t, archivePath)
|
||||
|
||||
rdb := openArchiveDBForRead(t, archivePath)
|
||||
|
||||
var rows []archivedRow
|
||||
|
||||
require.NoError(t, rdb.Table("archived_events").Find(&rows).Error)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, event.ID, rows[0].EventID)
|
||||
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
||||
assert.Equal(t, event.Method, rows[0].Method)
|
||||
assert.Equal(t, eventQuery, rows[0].RawQuery)
|
||||
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_ArchiveFailureFailsDelivery verifies that
|
||||
// an archive error (here: an unparseable expiry in the target
|
||||
// config) fails the delivery loudly: the attempt is recorded as
|
||||
// failed with the error and the delivery is marked failed, not
|
||||
// an archive error (here: an event with no webhook id to record)
|
||||
// fails the delivery loudly: the attempt is recorded as failed
|
||||
// with the error and the delivery is marked failed, not
|
||||
// delivered.
|
||||
func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
||||
t *testing.T,
|
||||
@@ -376,11 +270,12 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, `{"expiry":"nonsense"}`)
|
||||
tgt := env.seedDatabaseTarget(t)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"archived":false}`)
|
||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||
d.Event.WebhookID = ""
|
||||
|
||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||
|
||||
@@ -405,357 +300,96 @@ func TestDeliverDatabase_ArchiveFailureFailsDelivery(
|
||||
"the attempt must be recorded as failed",
|
||||
)
|
||||
assert.Contains(t,
|
||||
results[0].Error, "nonsense",
|
||||
results[0].Error, "without a webhook id",
|
||||
"the archive error must be recorded on the attempt",
|
||||
)
|
||||
|
||||
assert.NoFileExists(t,
|
||||
env.archivePath(tgt),
|
||||
"no archive file should exist for a failed config",
|
||||
"no archive file should exist for a failed write",
|
||||
)
|
||||
}
|
||||
|
||||
func TestValidateArchiveExpiry(t *testing.T) {
|
||||
t.Parallel()
|
||||
// seedArchivedRow creates the archive file at path holding one row, for
|
||||
// eventID, archived at archivedAt.
|
||||
func seedArchivedRow(
|
||||
t *testing.T, path, eventID string, archivedAt time.Time,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
valid := []string{"", "never", "1h", "720h", "30m"}
|
||||
for _, in := range valid {
|
||||
require.NoError(t,
|
||||
delivery.ValidateArchiveExpiry(in),
|
||||
"expiry %q should be accepted", in,
|
||||
)
|
||||
}
|
||||
|
||||
invalid := []string{"nonsense", "7d", "-5h", "0s", "0"}
|
||||
for _, in := range invalid {
|
||||
require.Error(t,
|
||||
delivery.ValidateArchiveExpiry(in),
|
||||
"expiry %q should be rejected", in,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArchiveFileName pins the archive file name and the rules
|
||||
// that make a webhook or target name safe to put in it.
|
||||
func TestArchiveFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
webhook string
|
||||
target string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"plain names", "orders", "archive",
|
||||
"archive-orders-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"lowercased", "Orders", "Main Archive",
|
||||
"archive-orders-main-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"a run of other characters is one dash",
|
||||
`a /\..b`, "c__--d",
|
||||
"archive-a-b-c-d-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"no dash at either end", " --orders!! ", "(archive)",
|
||||
"archive-orders-archive-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"path separators", "../../etc/passwd", "a/b",
|
||||
"archive-etc-passwd-a-b-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"letters outside ASCII are dropped",
|
||||
"Bestellungen Größe", "café",
|
||||
"archive-bestellungen-gr-e-caf-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"nothing left is unnamed", "", "!!!",
|
||||
"archive-unnamed-unnamed-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"cut to 40 characters", strings.Repeat("a", 50), "x",
|
||||
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
|
||||
},
|
||||
{
|
||||
"no dash left by the cut",
|
||||
strings.Repeat("a", 39) + " b", "x",
|
||||
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assert.Equal(
|
||||
t, tc.want,
|
||||
delivery.ArchiveFileName(tc.webhook, tc.target, id),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_EachTargetHasItsOwnArchive proves two
|
||||
// database targets of one webhook archive into separate files.
|
||||
func TestDeliverDatabase_EachTargetHasItsOwnArchive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
first := env.seedDatabaseTarget(t, "")
|
||||
second := env.addDatabaseTarget(t, first.WebhookID, "")
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
|
||||
for _, tgt := range []*database.Target{first, second} {
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
)
|
||||
}
|
||||
|
||||
require.NotEqual(
|
||||
t, env.archivePath(first), env.archivePath(second),
|
||||
sqlDB, err := sql.Open(
|
||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||
)
|
||||
assert.Equal(
|
||||
t, []string{event.ID},
|
||||
archivedEventIDs(t, env.archivePath(first)),
|
||||
)
|
||||
assert.Equal(
|
||||
t, []string{event.ID},
|
||||
archivedEventIDs(t, env.archivePath(second)),
|
||||
)
|
||||
}
|
||||
|
||||
// TestRename_MovesTheFile proves a rename moves the archive, rows
|
||||
// and all, and that later writes go to the new name.
|
||||
func TestRename_MovesTheFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
oldPath := env.archivePath(tgt)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
first := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
||||
)
|
||||
require.FileExists(t, oldPath)
|
||||
|
||||
require.NoError(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
)
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
|
||||
assert.NoFileExists(t, oldPath)
|
||||
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
|
||||
|
||||
second := seedEvent(t, webhookDB, `{"n":2}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
||||
)
|
||||
|
||||
assert.ElementsMatch(
|
||||
t, []string{first.ID, second.ID},
|
||||
archivedEventIDs(t, newPath),
|
||||
)
|
||||
assert.NoFileExists(
|
||||
t, oldPath, "a write after the rename must use the new name",
|
||||
)
|
||||
}
|
||||
|
||||
// TestRename_NeverReplacesAFile plants a file at the new name, once
|
||||
// the .db alone, once a lone -wal and once a lone -shm, and proves
|
||||
// each time that the rename is refused, the planted file survives,
|
||||
// and the archive keeps its name and its rows.
|
||||
func TestRename_NeverReplacesAFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
t.Run("planted .db"+suffix, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
oldPath := env.archivePath(tgt)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
first := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, first, tgt),
|
||||
)
|
||||
|
||||
newPath := filepath.Join(
|
||||
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||
)
|
||||
plantedPath := newPath + suffix
|
||||
require.NoError(
|
||||
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
|
||||
)
|
||||
|
||||
require.ErrorIs(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||
delivery.ErrArchiveNameTaken,
|
||||
)
|
||||
|
||||
//nolint:gosec // reads the file the test planted under t.TempDir()
|
||||
planted, err := os.ReadFile(plantedPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "planted", string(planted))
|
||||
|
||||
second := seedEvent(t, webhookDB, `{"n":2}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB,
|
||||
seedDatabaseTargetDelivery(t, webhookDB, second, tgt),
|
||||
)
|
||||
|
||||
assert.ElementsMatch(
|
||||
t, []string{first.ID, second.ID},
|
||||
archivedEventIDs(t, oldPath),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRename_BeforeTheNameIsSaved covers the order the handlers
|
||||
// use: they rename before they save the new name, so a delivery in
|
||||
// between must write under the new name although the main database
|
||||
// still has the old one. It also shows that renaming an archive that
|
||||
// does not exist yet is not an error.
|
||||
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := setupArchiveTest(t)
|
||||
tgt := env.seedDatabaseTarget(t, "")
|
||||
|
||||
require.NoError(
|
||||
t, env.eng.Rename(tgt.ID, "Orders", "Archive"),
|
||||
)
|
||||
|
||||
webhookDB := testWebhookDB(t)
|
||||
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||
env.eng.ExportDeliverDatabase(
|
||||
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||
)
|
||||
|
||||
assert.FileExists(
|
||||
t,
|
||||
filepath.Join(
|
||||
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
|
||||
),
|
||||
)
|
||||
assert.NoFileExists(t, env.archivePath(tgt))
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
|
||||
// the -wal and -shm a crash can leave beside an archive no handle
|
||||
// has opened since. SQLite finds them by name, so a -wal left
|
||||
// behind would lose the transactions it holds.
|
||||
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
newPath := filepath.Join(dir, "archive-new.db")
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
require.NoError(
|
||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||
)
|
||||
}
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
oldPath, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.NoError(t, w.Rename("archive-new.db"))
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
assert.NoFileExists(t, oldPath+suffix)
|
||||
assert.FileExists(t, newPath+suffix)
|
||||
}
|
||||
|
||||
assert.Equal(t, newPath, w.Path())
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
|
||||
// move after the .db has moved, and proves the .db is moved back, so
|
||||
// the archive is never split across two names. The new name is 255
|
||||
// bytes, the longest a file name may be, so the .db can take it but
|
||||
// the -wal, four bytes longer, cannot.
|
||||
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
newName := strings.Repeat("a", 252) + ".db"
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
require.NoError(
|
||||
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
|
||||
)
|
||||
}
|
||||
|
||||
w := delivery.NewExportArchiveWriter(
|
||||
oldPath, archiveTestLogger(), 0,
|
||||
)
|
||||
|
||||
require.Error(t, w.Rename(newName))
|
||||
|
||||
for _, suffix := range archiveFileSuffixes() {
|
||||
assert.FileExists(t, oldPath+suffix)
|
||||
}
|
||||
|
||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||
assert.Equal(t, oldPath, w.Path())
|
||||
}
|
||||
|
||||
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
|
||||
// file without a period and a file for a month, and proves that when
|
||||
// the month's file fails to move, the file already moved is moved back:
|
||||
// both files are under the old name with their rows, and nothing is
|
||||
// under the new name. The new name is 251 bytes, so the file without a
|
||||
// period, with its -wal and -shm, can take it, but the month's file,
|
||||
// eight bytes longer, cannot.
|
||||
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dir := t.TempDir()
|
||||
oldPath := filepath.Join(dir, "archive-old.db")
|
||||
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
|
||||
w := delivery.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
|
||||
|
||||
require.NoError(t, w.WritePeriod(
|
||||
delivery.ExportArchivedEvent{EventID: "in-none"}, 0, "",
|
||||
))
|
||||
require.NoError(t, w.WritePeriod(
|
||||
delivery.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
|
||||
))
|
||||
|
||||
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
|
||||
|
||||
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
|
||||
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
|
||||
|
||||
entries, err := os.ReadDir(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, entry := range entries {
|
||||
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
|
||||
"%s is not under the old name", entry.Name())
|
||||
}
|
||||
defer func() { _ = sqlDB.Close() }()
|
||||
|
||||
assert.Equal(t, oldPath, w.Path())
|
||||
gdb, err := gorm.Open(
|
||||
sqlite.Dialector{Conn: sqlDB},
|
||||
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, migrations.Apply(gdb, migrations.Archive))
|
||||
require.NoError(t, gdb.Exec(
|
||||
"INSERT INTO archived_events (event_id, archived_at) VALUES (?, ?)",
|
||||
eventID, archivedAt,
|
||||
).Error)
|
||||
}
|
||||
|
||||
// TestDeliverDatabase_TaskArchiveSettings proves a task's ArchiveExpiry
|
||||
// and ArchiveRotation reach the archive through the engine. The
|
||||
// target's row has neither, so only the task can bring them: with daily
|
||||
// rotation the event goes to the file for its day, and opening that
|
||||
// file with a one-hour expiry prunes the row it already held, archived
|
||||
// two hours ago.
|
||||
func TestDeliverDatabase_TaskArchiveSettings(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
webhook := database.Webhook{UserID: uuid.NewString(), Name: "settings"}
|
||||
webhook.ID = s.WebhookID
|
||||
require.NoError(t, s.MainDB.Create(&webhook).Error)
|
||||
|
||||
target := database.Target{
|
||||
WebhookID: s.WebhookID,
|
||||
Name: "archive",
|
||||
Type: database.TargetTypeDatabase,
|
||||
Active: true,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
require.NoError(t, s.MainDB.Create(&target).Error)
|
||||
|
||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"archived":true}`)
|
||||
d := iSeedDelivery(
|
||||
t, s.WebhookDB, event.ID, target.ID,
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, target.ID, target.Name, "", 1, 1,
|
||||
&event.Body,
|
||||
)
|
||||
task.TargetType = database.TargetTypeDatabase
|
||||
task.ArchiveExpiry = time.Hour
|
||||
task.ArchiveRotation = "daily"
|
||||
|
||||
daily := target
|
||||
daily.ArchiveRotation = task.ArchiveRotation
|
||||
dailyFile := archive.PathAt(s.DBMgr, &webhook, &daily, event.CreatedAt)
|
||||
seedArchivedRow(t, dailyFile, "expired", time.Now().Add(-2*time.Hour))
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
|
||||
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusDelivered)
|
||||
|
||||
var rows []archivedRow
|
||||
|
||||
require.NoError(t, openArchiveDBForRead(t, dailyFile).
|
||||
Table("archived_events").Find(&rows).Error)
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, event.ID, rows[0].EventID,
|
||||
"the event goes to its day's file and the expired row is pruned",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
const MaxTargetTimeoutSeconds = 300
|
||||
|
||||
// Errors returned when a target's header or timeout form input
|
||||
// cannot be turned into a configuration.
|
||||
// cannot be turned into the target's setting.
|
||||
//
|
||||
// None of these ever quotes a header VALUE. A target header value
|
||||
// is routinely an authorization token, and these messages are shown
|
||||
@@ -48,8 +48,8 @@ var (
|
||||
)
|
||||
|
||||
// isReservedTargetHeader reports whether name (canonicalised) is a
|
||||
// header a target configuration may not set, because the delivery
|
||||
// path or net/http itself writes it regardless.
|
||||
// header a target may not set, because the delivery path or
|
||||
// net/http itself writes it regardless.
|
||||
//
|
||||
// These are rejected rather than accepted-and-ignored. Storing a
|
||||
// header that provably never reaches the wire tells the operator
|
||||
@@ -77,13 +77,11 @@ func isReservedTargetHeader(name string) bool {
|
||||
|
||||
// ParseTargetHeaders turns the target form's headers field — one
|
||||
// "Name: value" pair per line, blank lines ignored — into the map
|
||||
// stored in HTTPTargetConfig.Headers. Names are canonicalised, so a
|
||||
// stored in database.Target.Headers. Names are canonicalised, so a
|
||||
// name repeated in a different case is still a duplicate rather than
|
||||
// one pair silently overwriting the other.
|
||||
//
|
||||
// An input with no pairs yields an empty map, which omitempty drops
|
||||
// from the stored config: a target configured with no headers keeps
|
||||
// the same config JSON it had before this field existed.
|
||||
// An input with no pairs yields an empty map.
|
||||
func ParseTargetHeaders(raw string) (map[string]string, error) {
|
||||
headers := make(map[string]string)
|
||||
|
||||
@@ -223,8 +221,8 @@ func FormatTargetHeaders(headers map[string]string) string {
|
||||
|
||||
// ParseTargetTimeout interprets the target form's timeout field as
|
||||
// a whole number of seconds. An empty field means "unset" and yields
|
||||
// 0, which omitempty drops from the stored config and which the
|
||||
// delivery path reads as "use the shared client's timeout".
|
||||
// 0, which the delivery path reads as "use the shared client's
|
||||
// timeout".
|
||||
//
|
||||
// Anything else that is not a whole number in range is an error, not
|
||||
// a silently substituted default: a target whose timeout was typed
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// Literals these tests repeat, named so that the header names and the
|
||||
// keep-forever archive config each have one definition.
|
||||
// Literals these tests repeat, named so that each header name has one
|
||||
// definition.
|
||||
const (
|
||||
headerAuthorization = "Authorization"
|
||||
headerContentType = "Content-Type"
|
||||
headerUserAgent = "User-Agent"
|
||||
bearerValue = "Bearer abc"
|
||||
archiveConfigNever = "{\"expiry\":\"never\"}"
|
||||
)
|
||||
|
||||
func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
|
||||
@@ -41,23 +39,13 @@ func TestParseTargetHeaders_AcceptsPairs(t *testing.T) {
|
||||
)
|
||||
}
|
||||
|
||||
// A configuration with no headers must stay indistinguishable from
|
||||
// one written before the field existed, so omitempty drops the key.
|
||||
// An input of blank lines is no headers, not an error.
|
||||
func TestParseTargetHeaders_EmptyInputYieldsNoHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got, err := delivery.ParseTargetHeaders("\n \n")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got)
|
||||
|
||||
encoded, err := json.Marshal(delivery.HTTPTargetConfig{
|
||||
URL: "https://example.com/h",
|
||||
Headers: got,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(
|
||||
t, `{"url":"https://example.com/h"}`, string(encoded),
|
||||
)
|
||||
}
|
||||
|
||||
func TestParseTargetHeaders_Rejects(t *testing.T) {
|
||||
@@ -181,87 +169,6 @@ func TestFormatTargetTimeout(t *testing.T) {
|
||||
assert.Empty(t, delivery.FormatTargetTimeout(-1))
|
||||
}
|
||||
|
||||
func TestNewTargetConfigForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
form, err := delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
Config: `{"url":"https://example.com/h",` +
|
||||
`"headers":{"Authorization":"Bearer abc"},` +
|
||||
`"timeout":9}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://example.com/h", form.URL)
|
||||
assert.Equal(t, "Authorization: Bearer abc\n", form.Headers)
|
||||
assert.Equal(t, "9", form.Timeout)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeSlack,
|
||||
Config: `{"webhookUrl":"https://hooks.example/s"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://hooks.example/s", form.URL)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"720h","rotation":"daily"}`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "720h", form.Expiry)
|
||||
assert.Equal(t, rotationDaily, form.Rotation)
|
||||
|
||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||
Type: database.TargetTypeLog,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, form.URL)
|
||||
}
|
||||
|
||||
// A keep-forever archive target yields an empty expiry, so the edit
|
||||
// form starts on never; saving it unchanged stores never, which means
|
||||
// the same as an empty expiry.
|
||||
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, cfg := range []string{"", `{}`, archiveConfigNever} {
|
||||
form, err := delivery.NewTargetConfigForm(
|
||||
&database.Target{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: cfg,
|
||||
},
|
||||
)
|
||||
require.NoError(t, err, cfg)
|
||||
assert.Empty(t, form.Expiry, cfg)
|
||||
}
|
||||
}
|
||||
|
||||
// An unreadable stored config is an error rather than a blank form
|
||||
// that looks like a target with no settings, so the caller can tell
|
||||
// the operator that saving replaces the stored value.
|
||||
func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []*database.Target{
|
||||
{Type: database.TargetTypeHTTP, Config: "not json"},
|
||||
{Type: database.TargetTypeHTTP, Config: `{}`},
|
||||
{Type: database.TargetTypeSlack, Config: ""},
|
||||
{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: `{"expiry":"soon"}`,
|
||||
},
|
||||
{
|
||||
Type: database.TargetTypeDatabase,
|
||||
Config: weeklyConfig,
|
||||
},
|
||||
{Type: database.TargetType("nope")},
|
||||
}
|
||||
|
||||
for _, target := range cases {
|
||||
_, err := delivery.NewTargetConfigForm(target)
|
||||
require.Error(t, err, target.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// The ceiling exists so one misconfigured target cannot hold a
|
||||
// delivery worker indefinitely, and it is inclusive.
|
||||
func TestParseTargetTimeout_CeilingIsInclusive(t *testing.T) {
|
||||
|
||||
@@ -4,10 +4,10 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -16,24 +16,6 @@ import (
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
)
|
||||
|
||||
// Sentinel errors returned by the config parsers.
|
||||
var (
|
||||
errEmptyTargetConfig = errors.New(
|
||||
"empty target config",
|
||||
)
|
||||
errMissingTargetURL = errors.New(
|
||||
"target URL is required",
|
||||
)
|
||||
)
|
||||
|
||||
// HTTPTargetConfig holds configuration for http target
|
||||
// types.
|
||||
type HTTPTargetConfig struct {
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers,omitempty"`
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// httpCore holds the retry, backoff, and circuit-breaker
|
||||
// machinery shared by the HTTP and Slack targets. Each of
|
||||
// those targets owns its own httpCore instance (and thus its
|
||||
@@ -47,34 +29,72 @@ type httpCore struct {
|
||||
}
|
||||
|
||||
// deliver runs one delivery attempt through the retry core.
|
||||
// A maxRetries of 0 is fire-and-forget: a single attempt is
|
||||
// recorded and no circuit breaker is consulted. A positive
|
||||
// maxRetries gates the attempt on the circuit breaker and
|
||||
// schedules a backed-off retry on failure.
|
||||
// A maxAttempts of 1 is a single attempt, recorded without
|
||||
// consulting a circuit breaker. A higher maxAttempts gates the
|
||||
// attempt on the circuit breaker and schedules a backed-off retry
|
||||
// on failure, until maxAttempts attempts have been made.
|
||||
//
|
||||
// ctx is the context attempt sends its request with. Stopping the
|
||||
// engine cancels it; see interruptedByStop.
|
||||
func (c *httpCore) deliver(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
sched Scheduler,
|
||||
maxRetries int,
|
||||
maxAttempts int,
|
||||
attempt func() attemptResult,
|
||||
) {
|
||||
if maxRetries == 0 {
|
||||
c.fireAndForget(webhookDB, d, attempt())
|
||||
if maxAttempts == 1 {
|
||||
c.fireAndForget(ctx, webhookDB, d, attempt())
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
c.withRetry(
|
||||
webhookDB, d, task, sched, maxRetries, attempt,
|
||||
ctx, webhookDB, d, task, sched, maxAttempts, attempt,
|
||||
)
|
||||
}
|
||||
|
||||
// interruptedByStop reports whether an attempt failed because the
|
||||
// engine is stopping, which cancels the context its request is made
|
||||
// with. Such an attempt is not recorded: no DeliveryResult, no circuit
|
||||
// breaker outcome, no status change, no count in the attempt metrics.
|
||||
// The delivery stays pending or retrying, and the recovery routine
|
||||
// sends it again on the next start.
|
||||
//
|
||||
// A target's own timeout leaves that context alone, so its attempt is
|
||||
// still recorded as the failure it is.
|
||||
func (c *httpCore) interruptedByStop(
|
||||
ctx context.Context,
|
||||
d *database.Delivery,
|
||||
res attemptResult,
|
||||
) bool {
|
||||
if res.success || ctx.Err() == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
c.eng.log.Warn(
|
||||
"delivery attempt interrupted by shutdown; not recorded, "+
|
||||
"it is sent again on the next start",
|
||||
"delivery_id", d.ID,
|
||||
"event_id", d.EventID,
|
||||
"target_id", d.TargetID,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *httpCore) fireAndForget(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
res attemptResult,
|
||||
) {
|
||||
if c.interruptedByStop(ctx, d, res) {
|
||||
return
|
||||
}
|
||||
|
||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||
|
||||
err := c.eng.recordResult(
|
||||
@@ -104,11 +124,12 @@ func (c *httpCore) fireAndForget(
|
||||
}
|
||||
|
||||
func (c *httpCore) withRetry(
|
||||
ctx context.Context,
|
||||
webhookDB *gorm.DB,
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
sched Scheduler,
|
||||
maxRetries int,
|
||||
maxAttempts int,
|
||||
attempt func() attemptResult,
|
||||
) {
|
||||
cb := c.getCircuitBreaker(task.TargetID)
|
||||
@@ -125,6 +146,10 @@ func (c *httpCore) withRetry(
|
||||
|
||||
res := attempt()
|
||||
|
||||
if c.interruptedByStop(ctx, d, res) {
|
||||
return
|
||||
}
|
||||
|
||||
c.eng.observeAttempt(d.Target.Type, res.elapsed())
|
||||
|
||||
err := c.eng.recordResult(
|
||||
@@ -156,7 +181,7 @@ func (c *httpCore) withRetry(
|
||||
cb.RecordFailure()
|
||||
|
||||
c.handleRetry(
|
||||
webhookDB, d, task, sched, maxRetries, attemptNum,
|
||||
webhookDB, d, task, sched, maxAttempts, attemptNum,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -217,10 +242,10 @@ func (c *httpCore) handleRetry(
|
||||
d *database.Delivery,
|
||||
task *Task,
|
||||
sched Scheduler,
|
||||
maxRetries int,
|
||||
maxAttempts int,
|
||||
attemptNum int,
|
||||
) {
|
||||
if attemptNum >= maxRetries {
|
||||
if attemptNum >= maxAttempts {
|
||||
c.eng.settleStatus(
|
||||
webhookDB, d, d.Target.Type,
|
||||
database.DeliveryStatusFailed,
|
||||
@@ -285,7 +310,8 @@ func (c *httpCore) publishCircuitState(
|
||||
|
||||
// remainingBackoff returns how long remains of the backoff
|
||||
// window for the last attempt of a recovered retrying
|
||||
// delivery. It implements rescheduler.
|
||||
// delivery: zero once it has passed, or when the last attempt
|
||||
// cannot be read. It implements rescheduler.
|
||||
func (c *httpCore) remainingBackoff(
|
||||
webhookDB *gorm.DB,
|
||||
deliveryID string,
|
||||
@@ -308,29 +334,6 @@ func (c *httpCore) remainingBackoff(
|
||||
return max(remaining, 0)
|
||||
}
|
||||
|
||||
// backoffElapsed reports whether the backoff window for the
|
||||
// last attempt of a retrying delivery has passed. It
|
||||
// implements rescheduler.
|
||||
func (c *httpCore) backoffElapsed(
|
||||
webhookDB *gorm.DB,
|
||||
deliveryID string,
|
||||
attemptNum int,
|
||||
) bool {
|
||||
var lastResult database.DeliveryResult
|
||||
|
||||
err := webhookDB.
|
||||
Where("delivery_id = ?", deliveryID).
|
||||
Order("created_at DESC").
|
||||
First(&lastResult).Error
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
backoff := Backoff(attemptNum)
|
||||
|
||||
return time.Since(lastResult.CreatedAt) >= backoff
|
||||
}
|
||||
|
||||
// Backoff is how long an http or slack target with retries waits after
|
||||
// a delivery's failed attempt attemptNum before trying it again.
|
||||
func Backoff(attemptNum int) time.Duration {
|
||||
@@ -358,39 +361,13 @@ func (t *httpTarget) Deliver(
|
||||
task *Task,
|
||||
sched Scheduler,
|
||||
) {
|
||||
cfg, err := parseHTTPConfig(d.Target.Config)
|
||||
if err != nil {
|
||||
t.eng.log.Error(
|
||||
"invalid HTTP target config",
|
||||
"target_id", d.TargetID,
|
||||
"error", err,
|
||||
)
|
||||
|
||||
recErr := t.eng.recordResult(
|
||||
webhookDB, d, task.AttemptNum,
|
||||
false, 0, "", err.Error(), 0,
|
||||
)
|
||||
if recErr != nil {
|
||||
t.eng.bookkeepingFailed(d, recErr)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
t.eng.settleStatus(
|
||||
webhookDB, d, d.Target.Type,
|
||||
database.DeliveryStatusFailed,
|
||||
)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
attempt := func() attemptResult {
|
||||
return t.attempt(ctx, cfg, &d.Event)
|
||||
return t.attempt(ctx, &d.Target, &d.Event)
|
||||
}
|
||||
|
||||
t.deliver(
|
||||
webhookDB, d, task, sched,
|
||||
d.Target.MaxRetries, attempt,
|
||||
ctx, webhookDB, d, task, sched,
|
||||
d.Target.MaxAttempts, attempt,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -400,11 +377,11 @@ func (t *httpTarget) Deliver(
|
||||
// string; only a transport-level error does.
|
||||
func (t *httpTarget) attempt(
|
||||
ctx context.Context,
|
||||
cfg *HTTPTargetConfig,
|
||||
target *database.Target,
|
||||
event *database.Event,
|
||||
) attemptResult {
|
||||
statusCode, respBody, duration, reqErr :=
|
||||
t.doHTTPRequest(ctx, cfg, event)
|
||||
t.doHTTPRequest(ctx, target, event)
|
||||
|
||||
success := reqErr == nil &&
|
||||
statusCode >= httpSuccessMin &&
|
||||
@@ -426,7 +403,7 @@ func (t *httpTarget) attempt(
|
||||
|
||||
func (t *httpTarget) doHTTPRequest(
|
||||
ctx context.Context,
|
||||
cfg *HTTPTargetConfig,
|
||||
target *database.Target,
|
||||
event *database.Event,
|
||||
) (int, string, int64, error) {
|
||||
start := time.Now()
|
||||
@@ -434,7 +411,7 @@ func (t *httpTarget) doHTTPRequest(
|
||||
req, reqErr := http.NewRequestWithContext(
|
||||
ctx,
|
||||
http.MethodPost,
|
||||
cfg.URL,
|
||||
target.URL,
|
||||
bytes.NewReader([]byte(event.Body)),
|
||||
)
|
||||
if reqErr != nil {
|
||||
@@ -444,11 +421,15 @@ func (t *httpTarget) doHTTPRequest(
|
||||
)
|
||||
}
|
||||
|
||||
if target.ForwardQuery {
|
||||
appendQuery(req.URL, event.RawQuery)
|
||||
}
|
||||
|
||||
originScoped := applyRequestHeaders(
|
||||
req, event, cfg, t.eng.userAgent(),
|
||||
req, event, target.Headers, t.eng.userAgent(),
|
||||
)
|
||||
|
||||
client := t.clientForRequest(cfg, originScoped)
|
||||
client := t.clientForRequest(target.TimeoutSeconds, originScoped)
|
||||
|
||||
resp, doErr := executeHTTPRequest(client, req)
|
||||
|
||||
@@ -474,18 +455,31 @@ func (t *httpTarget) doHTTPRequest(
|
||||
return resp.StatusCode, string(body), dur, nil
|
||||
}
|
||||
|
||||
// appendQuery adds an event's query string to a delivery's URL, joined
|
||||
// with "&" to any query string the target URL already has.
|
||||
func appendQuery(u *url.URL, rawQuery string) {
|
||||
switch {
|
||||
case rawQuery == "":
|
||||
return
|
||||
case u.RawQuery == "":
|
||||
u.RawQuery = rawQuery
|
||||
default:
|
||||
u.RawQuery += "&" + rawQuery
|
||||
}
|
||||
}
|
||||
|
||||
// clientForRequest returns the client for one delivery attempt.
|
||||
// originScoped is the header set applyRequestHeaders built for that
|
||||
// attempt; a request with neither a per-target timeout nor an
|
||||
// origin-scoped header gets the shared client, because there is
|
||||
// then nothing for the redirect policy to strip and net/http's
|
||||
// default policy already withholds Authorization and Cookie across
|
||||
// hosts.
|
||||
// timeoutSeconds is the target's TimeoutSeconds, and originScoped is
|
||||
// the header set applyRequestHeaders built for that attempt; a
|
||||
// request with neither a per-target timeout nor an origin-scoped
|
||||
// header gets the shared client, because there is then nothing for
|
||||
// the redirect policy to strip and net/http's default policy already
|
||||
// withholds Authorization and Cookie across hosts.
|
||||
func (t *httpTarget) clientForRequest(
|
||||
cfg *HTTPTargetConfig,
|
||||
timeoutSeconds int,
|
||||
originScoped []string,
|
||||
) *http.Client {
|
||||
if cfg.Timeout <= 0 && len(originScoped) == 0 {
|
||||
if timeoutSeconds <= 0 && len(originScoped) == 0 {
|
||||
return t.client
|
||||
}
|
||||
|
||||
@@ -498,9 +492,9 @@ func (t *httpTarget) clientForRequest(
|
||||
Transport: t.client.Transport,
|
||||
}
|
||||
|
||||
if cfg.Timeout > 0 {
|
||||
if timeoutSeconds > 0 {
|
||||
client.Timeout = time.Duration(
|
||||
cfg.Timeout,
|
||||
timeoutSeconds,
|
||||
) * time.Second
|
||||
}
|
||||
|
||||
@@ -511,31 +505,6 @@ func (t *httpTarget) clientForRequest(
|
||||
return client
|
||||
}
|
||||
|
||||
func parseHTTPConfig(
|
||||
configJSON string,
|
||||
) (*HTTPTargetConfig, error) {
|
||||
if configJSON == "" {
|
||||
return nil, errEmptyTargetConfig
|
||||
}
|
||||
|
||||
var cfg HTTPTargetConfig
|
||||
|
||||
err := json.Unmarshal(
|
||||
[]byte(configJSON), &cfg,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"parsing config JSON: %w", err,
|
||||
)
|
||||
}
|
||||
|
||||
if cfg.URL == "" {
|
||||
return nil, errMissingTargetURL
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// isForwardableHeader returns true if the header should
|
||||
// be forwarded to targets.
|
||||
func isForwardableHeader(name string) bool {
|
||||
@@ -567,11 +536,12 @@ func isForwardableHeader(name string) bool {
|
||||
// wins, otherwise the event's ContentType, otherwise none. The inbound
|
||||
// Content-Type in the event's headers is never forwarded.
|
||||
//
|
||||
// userAgent is set last, over any configured or inbound User-Agent.
|
||||
// configured is the target's Headers. userAgent is set last, over
|
||||
// any configured or inbound User-Agent.
|
||||
func applyRequestHeaders(
|
||||
req *http.Request,
|
||||
event *database.Event,
|
||||
cfg *HTTPTargetConfig,
|
||||
configured map[string]string,
|
||||
userAgent string,
|
||||
) []string {
|
||||
if event.ContentType != "" {
|
||||
@@ -582,7 +552,7 @@ func applyRequestHeaders(
|
||||
|
||||
originScoped := forwardEventHeaders(req, event)
|
||||
|
||||
for k, v := range cfg.Headers {
|
||||
for k, v := range configured {
|
||||
req.Header.Set(k, v)
|
||||
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
|
||||
}
|
||||
@@ -647,7 +617,7 @@ func forwardEventHeaders(
|
||||
}
|
||||
|
||||
// executeHTTPRequest sends an HTTP request using the provided
|
||||
// client. URLs are validated by the config parsers and the
|
||||
// client. URLs are validated by the target forms and the
|
||||
// SSRF-safe transport before reaching here.
|
||||
//
|
||||
// Transport failures are masked here, at the single point
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package delivery_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/webhooker/internal/database"
|
||||
"sneak.berlin/go/webhooker/internal/delivery"
|
||||
)
|
||||
|
||||
// eventQuery is the query string the events in these tests arrived
|
||||
// with.
|
||||
const eventQuery = "a=1&b=2"
|
||||
|
||||
// deliverWithQuery sends one event that arrived with eventQuery to an
|
||||
// HTTP target at targetURL, with its ForwardQuery setting as given,
|
||||
// through the path a received event's delivery takes, and returns the
|
||||
// attempt it recorded.
|
||||
func deliverWithQuery(
|
||||
t *testing.T, targetURL string, forwardQuery bool,
|
||||
) database.DeliveryResult {
|
||||
t.Helper()
|
||||
|
||||
s := newISetup(t)
|
||||
|
||||
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, "{}")
|
||||
d := iSeedDelivery(
|
||||
t, s.WebhookDB, event.ID, uuid.NewString(),
|
||||
database.DeliveryStatusPending,
|
||||
)
|
||||
task := iTask(
|
||||
d, event, s.WebhookID, d.TargetID, "query", targetURL, 1, 1,
|
||||
&event.Body,
|
||||
)
|
||||
task.ForwardQuery = forwardQuery
|
||||
task.RawQuery = eventQuery
|
||||
|
||||
s.Engine.ExportProcessNewTask(context.TODO(), &task)
|
||||
|
||||
var result database.DeliveryResult
|
||||
|
||||
require.NoError(t, s.WebhookDB.Where(
|
||||
"delivery_id = ?", d.ID,
|
||||
).First(&result).Error)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// TestDeliverHTTP_ForwardQuery proves the URL a delivery is sent to:
|
||||
// with the target's setting off, the target URL exactly as configured;
|
||||
// with it on, the event's query string appended, joined with "&" to a
|
||||
// query string the target URL already has.
|
||||
func TestDeliverHTTP_ForwardQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// The target URL's path, without and with a query string of its
|
||||
// own.
|
||||
const (
|
||||
plain = "/in"
|
||||
withQuery = "/in?key=k"
|
||||
)
|
||||
|
||||
tests := map[string]struct {
|
||||
path string
|
||||
forwardQuery bool
|
||||
want string
|
||||
}{
|
||||
"off": {
|
||||
path: plain, want: plain,
|
||||
},
|
||||
"off, the target URL has a query string": {
|
||||
path: withQuery, want: withQuery,
|
||||
},
|
||||
"on": {
|
||||
path: plain, forwardQuery: true, want: plain + "?" + eventQuery,
|
||||
},
|
||||
"on, the target URL has a query string": {
|
||||
path: withQuery, forwardQuery: true,
|
||||
want: withQuery + "&" + eventQuery,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tc := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
received := make(chan string, 1)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
received <- r.RequestURI
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
},
|
||||
))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
result := deliverWithQuery(
|
||||
t, ts.URL+tc.path, tc.forwardQuery,
|
||||
)
|
||||
|
||||
assert.True(t, result.Success)
|
||||
require.Len(t, received, 1)
|
||||
assert.Equal(t, tc.want, <-received)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked proves the
|
||||
// credential in a target URL's own query string stays masked once the
|
||||
// event's query string is appended to it: in a response or error that
|
||||
// echoes the URL the target was sent, as the event log's Redactor shows
|
||||
// it, and in the error a failed connection stores.
|
||||
func TestDeliverHTTP_ForwardedQueryKeepsTheTargetURLMasked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const secret = "s3cr3t"
|
||||
|
||||
received := make(chan string, 1)
|
||||
|
||||
ts := httptest.NewServer(http.HandlerFunc(
|
||||
func(w http.ResponseWriter, r *http.Request) {
|
||||
received <- r.RequestURI
|
||||
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
},
|
||||
))
|
||||
t.Cleanup(ts.Close)
|
||||
|
||||
target := &database.Target{
|
||||
Type: database.TargetTypeHTTP,
|
||||
URL: ts.URL + "/in?token=" + secret,
|
||||
ForwardQuery: true,
|
||||
MaxAttempts: 1,
|
||||
}
|
||||
|
||||
deliverWithQuery(t, target.URL, target.ForwardQuery)
|
||||
require.Len(t, received, 1)
|
||||
|
||||
sent := <-received
|
||||
require.Equal(t, "/in?token="+secret+"&"+eventQuery, sent)
|
||||
|
||||
redactor := delivery.NewRedactor(target)
|
||||
|
||||
for _, echoed := range []string{sent, ts.URL + sent} {
|
||||
shown := redactor.Redact("rejected " + echoed)
|
||||
assert.NotContains(t, shown, secret, echoed)
|
||||
assert.Contains(t, shown, delivery.RedactionMarker, echoed)
|
||||
}
|
||||
|
||||
// Nothing listens on port 1.
|
||||
failed := deliverWithQuery(
|
||||
t, "http://127.0.0.1:1/in?token="+secret, true,
|
||||
)
|
||||
require.NotEmpty(t, failed.Error)
|
||||
assert.NotContains(t, failed.Error, secret)
|
||||
assert.NotContains(t, failed.Error, eventQuery)
|
||||
}
|
||||
@@ -9,9 +9,9 @@ import (
|
||||
)
|
||||
|
||||
// logTarget is a fire-and-forget target that logs the entire
|
||||
// inbound webhook — the full request body and headers, plus
|
||||
// the method, content type, and the webhook and entrypoint
|
||||
// ids — then records a single successful attempt.
|
||||
// inbound webhook — the full request body, query string and
|
||||
// headers, plus the method, content type, and the webhook and
|
||||
// entrypoint ids — then records a single successful attempt.
|
||||
//
|
||||
// This is the one log call in the service that deliberately writes
|
||||
// unbounded client-chosen bytes, so it is the one exception to the
|
||||
@@ -46,6 +46,7 @@ func (t *logTarget) Deliver(
|
||||
"webhook_id", d.Event.WebhookID,
|
||||
"entrypoint_id", d.Event.EntrypointID,
|
||||
"method", d.Event.Method,
|
||||
"raw_query", d.Event.RawQuery,
|
||||
"content_type", d.Event.ContentType,
|
||||
"headers", d.Event.Headers,
|
||||
"body", d.Event.Body,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user