Compare commits
52
Commits
9d963cac83
..
next
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
128eb1b644 | ||
|
|
3de345fe6f | ||
|
|
a9d77e20d7 | ||
|
|
faf3da9a75 | ||
|
|
fa6a9ed4dc | ||
|
|
46fe7baed0 | ||
|
|
ea8cba7264 | ||
|
|
a8fc0c5d32 | ||
|
|
7963188c1e | ||
|
|
16ed356b68 | ||
|
|
fe5e0d4173 | ||
|
|
9ccaa8ce01 | ||
|
|
935e18c6f1 | ||
|
|
af91d8a723 | ||
|
|
7e779f7fce | ||
|
|
9079a3219d | ||
|
|
b9ec91c0f7 | ||
|
|
74a96b2226 | ||
|
|
8b617efa63 | ||
|
|
d8c60c9b67 | ||
|
|
ea8384f4a2 | ||
|
|
17e6c85dd8 | ||
|
|
bcdd4791ec | ||
|
|
f1da5e73dd | ||
|
|
d2ecb83923 | ||
|
|
643077021d | ||
|
|
22fa502638 | ||
|
|
6395210474 | ||
|
|
3489d6909a | ||
|
|
f282c6363d | ||
|
|
61371d388e | ||
|
|
19a6705c63 | ||
|
|
93911f28f9 | ||
|
|
9305af4f85 | ||
|
|
ff24638ba4 | ||
|
|
da75950e91 | ||
|
|
719d7013ee | ||
|
|
faf7ca1a5e | ||
|
|
0f5f6ba6bf | ||
|
|
820d9391ff | ||
|
|
2967c475a1 | ||
|
|
35d2f28c67 | ||
|
|
9526e961b5 | ||
|
|
4915d60d8e | ||
|
|
0945831442 | ||
|
|
f82b730c31 | ||
|
|
1a1fee0874 | ||
|
|
290925f184 | ||
|
|
73353bc8e5 | ||
|
|
40f59ec4d2 | ||
|
|
806c95e305 | ||
|
|
f703b72ce0 |
+83
-26
@@ -1,27 +1,84 @@
|
|||||||
# .git is sent so the build can derive the version it stamps into the binary
|
# .dockerignore does NOT use .gitignore semantics. Docker matches with
|
||||||
# (script/version). Its config, which can hold a remote URL carrying a
|
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
|
||||||
# credential and which `git describe` does not need, is left out of a
|
# `/` and an unprefixed pattern is anchored at the context root. Every
|
||||||
# directory context. A context sent as a tar is not filtered by this file, so
|
# depth-independent pattern therefore needs `**/`, or `config/.env` and
|
||||||
# it carries .git/config unless its sender leaves it out.
|
# `certs/server.key` still ship while this file reads as solved. Only
|
||||||
.git/config
|
# genuinely root-anchored entries go unprefixed. Never transplant these
|
||||||
|
# into .gitignore, where `**/` is wrong.
|
||||||
# No tracked file may be listed here: git in the build would see it as
|
|
||||||
# deleted and mark the version -dirty.
|
|
||||||
#
|
#
|
||||||
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
|
# Matching is case-sensitive, so secrets use character ranges rather
|
||||||
# that keeps the check stages from replaying a cached pass. See the lint
|
# than an ALL-CAPS twin, which would still miss `Server.Key`.
|
||||||
# stage of the Dockerfile.
|
#
|
||||||
bin/
|
# Extend with this repo's own host-built artifacts, written anchored:
|
||||||
# Extracted from 3p/ by `make assets` inside the build; a host copy is not
|
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
|
||||||
# needed. The tarball in 3p/ must stay in the context.
|
# deletes the package directory from the context.
|
||||||
static/js/alpine.min.js
|
|
||||||
.env
|
# .git is sent without its config. Without a VERSION build argument the
|
||||||
.env.*
|
# stage that compiles runs `git describe --tags --always` on .git, which
|
||||||
*.db
|
# does not need .git/config; that file can hold a credential, such as a
|
||||||
*.sqlite
|
# password in a remote URL or the token the CI checkout step stores there.
|
||||||
*.sqlite3
|
# Each submodule keeps a config with the same exposure in its git directory
|
||||||
.DS_Store
|
# under .git/modules/, nested again for a submodule's own submodules, or in
|
||||||
.idea/
|
# its own .git directory when it keeps one.
|
||||||
.vscode/
|
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
|
||||||
tmp/
|
# `deploy/config`, `config/lib`) loses its whole git directory, because
|
||||||
temp/
|
# `**/.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]
|
[Makefile]
|
||||||
indent_style = tab
|
indent_style = tab
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|||||||
@@ -1,37 +1,9 @@
|
|||||||
name: check
|
name: check
|
||||||
|
on: [push]
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- '**'
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
# actions/checkout v4.2.2, 2026-02-22
|
||||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
|
||||||
with:
|
- run: script/cibuild
|
||||||
# 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 both check stages: a commit
|
|
||||||
# that was never linted, format-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, make test, make build)
|
|
||||||
run: script/cibuild
|
|
||||||
|
|||||||
+50
-18
@@ -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
|
# Binaries
|
||||||
*.exe
|
*.exe
|
||||||
*.dll
|
*.dll
|
||||||
@@ -15,21 +65,6 @@ bin/
|
|||||||
# Go vendor directory
|
# Go vendor directory
|
||||||
vendor/
|
vendor/
|
||||||
|
|
||||||
# IDE specific files
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
*~
|
|
||||||
.vscode/
|
|
||||||
|
|
||||||
# OS specific files
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Environment and config files
|
|
||||||
.env
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# Data directory (SQLite databases)
|
# Data directory (SQLite databases)
|
||||||
data/
|
data/
|
||||||
*.db
|
*.db
|
||||||
@@ -43,9 +78,6 @@ data/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
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
|
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
|
||||||
# what is committed.
|
# what is committed.
|
||||||
/static/js/alpine.min.js
|
/static/js/alpine.min.js
|
||||||
|
|||||||
+73
-2
@@ -10,14 +10,21 @@ run:
|
|||||||
|
|
||||||
linters:
|
linters:
|
||||||
default: all
|
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:
|
disable:
|
||||||
# Genuinely incompatible with project patterns
|
# Genuinely incompatible with project patterns
|
||||||
- exhaustruct # Requires all struct fields
|
- 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
|
- godot # Requires comments to end with periods
|
||||||
- wsl # Deprecated, replaced by wsl_v5
|
|
||||||
- wrapcheck # Too verbose for internal packages
|
- wrapcheck # Too verbose for internal packages
|
||||||
- varnamelen # Short names like db, id are idiomatic Go
|
- 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:
|
settings:
|
||||||
lll:
|
lll:
|
||||||
line-length: 88
|
line-length: 88
|
||||||
@@ -28,6 +35,70 @@ linters:
|
|||||||
max-complexity: 15
|
max-complexity: 15
|
||||||
dupl:
|
dupl:
|
||||||
threshold: 100
|
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:
|
issues:
|
||||||
max-issues-per-linter: 0
|
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
|
||||||
+172
-37
@@ -1,10 +1,84 @@
|
|||||||
# Lint stage
|
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
# tailwindcss, from static/css/input.css and the files its @source lines
|
||||||
|
# name. `make css` (script/css) writes it out from the css-output stage.
|
||||||
|
# The css-check stage fails when the committed file differs from what is
|
||||||
|
# generated; `make check` runs it, and so does the build stage below.
|
||||||
|
#
|
||||||
|
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
|
||||||
|
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
|
||||||
|
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
|
||||||
|
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
|
||||||
|
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
|
||||||
|
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
|
||||||
|
|
||||||
|
# TARGETARCH, set by docker, is the architecture being built for.
|
||||||
|
FROM tailwind-${TARGETARCH} AS css
|
||||||
|
WORKDIR /src
|
||||||
|
COPY . .
|
||||||
|
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
|
||||||
|
|
||||||
|
FROM scratch AS css-output
|
||||||
|
COPY --from=css /out/tailwind.css /
|
||||||
|
|
||||||
|
# Both files are split after each "}", one rule per line, so that when they
|
||||||
|
# differ the diff shows the rules that differ.
|
||||||
|
FROM css AS css-check
|
||||||
|
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
|
||||||
|
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
|
||||||
|
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
|
||||||
|
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
|
||||||
|
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock
|
||||||
|
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
|
||||||
|
# prettier for the Markdown stages below. The lint phase below runs js-lint.
|
||||||
|
#
|
||||||
|
# The image's own corepack runs the yarn that package.json's packageManager
|
||||||
|
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
|
||||||
|
# hash there. The image also ships yarn 1, which `corepack enable yarn`
|
||||||
|
# replaces.
|
||||||
|
# node:24.21.0-alpine (LTS), 2026-09-18
|
||||||
|
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
|
||||||
|
WORKDIR /src
|
||||||
|
COPY package.json yarn.lock .yarnrc.yml ./
|
||||||
|
RUN corepack enable yarn && yarn install --immutable --mode=skip-build
|
||||||
|
|
||||||
|
FROM js-deps AS js-lint
|
||||||
|
COPY . .
|
||||||
|
RUN --network=none node_modules/.bin/eslint static/js
|
||||||
|
|
||||||
|
# Markdown stages: prettier, at the version package.json and yarn.lock pin,
|
||||||
|
# formats every Markdown file in the tree with the settings in .prettierrc.
|
||||||
|
# `make fmt` (script/fmt) writes the formatted files out from markdown-output.
|
||||||
|
# markdown-check fails on any file prettier would change; `make fmt-check`
|
||||||
|
# runs it, and so does the build stage below.
|
||||||
|
FROM js-deps AS markdown
|
||||||
|
COPY . .
|
||||||
|
RUN --network=none node_modules/.bin/prettier --write '**/*.md' \
|
||||||
|
&& mkdir /out \
|
||||||
|
&& find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \;
|
||||||
|
|
||||||
|
FROM scratch AS markdown-output
|
||||||
|
COPY --from=markdown /out /
|
||||||
|
|
||||||
|
FROM js-deps AS markdown-check
|
||||||
|
COPY . .
|
||||||
|
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
|
||||||
|
|
||||||
|
# Lint phase: the Go formatting check and golangci-lint over the Go code,
|
||||||
|
# and ESLint over static/js/ through the copy from js-lint at the end.
|
||||||
|
# `make lint` (script/lint) builds this stage alone; the build stage below
|
||||||
|
# depends on it.
|
||||||
|
#
|
||||||
|
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
|
||||||
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
|
||||||
# compile on Alpine musl (off64_t is a glibc type).
|
# compile on Alpine musl (off64_t is a glibc type).
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -12,34 +86,99 @@ WORKDIR /src
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code. In CI the context also carries .ci-fingerprint, which
|
|
||||||
# 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 . .
|
COPY . .
|
||||||
|
|
||||||
# Run formatting check and linter. golangci-lint is invoked directly rather
|
# gofmt and golangci-lint are invoked directly rather than through `make
|
||||||
# than through `make lint`: this stage is already the pinned linter image, and
|
# fmt-check` and `make lint`, which are themselves docker builds and would
|
||||||
# script/lint is a wrapper that builds Dockerfile.lint, so calling it here
|
# need a docker daemon inside this one. The Markdown half of `make
|
||||||
# would need a docker daemon inside the build. Keep these steps in step with
|
# fmt-check` is the markdown-check stage above.
|
||||||
# Dockerfile.lint, including --network=none (see its header for why).
|
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
|
||||||
RUN make fmt-check
|
|
||||||
|
# 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
|
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 ./...
|
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
|
||||||
|
|
||||||
|
# Nothing is wanted from js-lint; the copy is what makes this phase run it.
|
||||||
|
COPY --from=js-lint /src/yarn.lock /dev/null
|
||||||
|
|
||||||
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go image
|
||||||
|
# ships and the alpine one does not. `make test` (script/test) builds this
|
||||||
|
# stage alone; the build stage below depends on it.
|
||||||
|
#
|
||||||
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# static/static.go embeds the Alpine.js file this extracts from 3p/.
|
||||||
|
RUN script/assets
|
||||||
|
|
||||||
|
# -timeout applies to each package on its own, so 90s has only to clear the
|
||||||
|
# slowest one. -p 4 -parallel 8 keep the run under 2 GB of memory: at most
|
||||||
|
# four test binaries build or run at once, each with at most eight parallel
|
||||||
|
# tests. Under -race every test binary and every link costs a few hundred MB,
|
||||||
|
# so the defaults (one per core) add up to several GB on a many-core host.
|
||||||
|
#
|
||||||
|
# The first run has no -v: go test then prints one result line per package,
|
||||||
|
# with its coverage, and for a package that fails, everything its tests
|
||||||
|
# wrote. Verbose output from the whole suite passes the 2 MiB at which the
|
||||||
|
# Docker build cuts off a step's log, so on a failure only the tests that
|
||||||
|
# failed run again, with -v. go test reports a failed test as a line starting
|
||||||
|
# "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with
|
||||||
|
# its parent) and a failed package as "FAIL<tab>package/path<tab>...". A
|
||||||
|
# failure that names no test, such as a build error or a timeout, is already
|
||||||
|
# shown in full, so there is nothing to rerun. The step fails after the rerun
|
||||||
|
# whatever its result: the first run already showed the suite is broken.
|
||||||
|
#
|
||||||
|
# TMPDIR, where the tests keep their SQLite databases, is a tmpfs: SQLite
|
||||||
|
# waits for the disk at every commit, and on a busy host that waiting was
|
||||||
|
# about 40% of the slowest package's run time. GOTMPDIR keeps go's own
|
||||||
|
# build files, the test binaries among them, on disk.
|
||||||
|
#
|
||||||
|
# bash with pipefail, so that the first run's status is go test's, not tee's.
|
||||||
|
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||||
|
RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
|
||||||
|
export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \
|
||||||
|
go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \
|
||||||
|
tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \
|
||||||
|
packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \
|
||||||
|
if [ -n "$tests" ]; then \
|
||||||
|
echo "--- Rerunning the failed tests with -v for details ---"; \
|
||||||
|
go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \
|
||||||
|
fi; \
|
||||||
|
exit 1
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
|
||||||
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
# Using Debian-based image because gorm.io/driver/sqlite pulls in
|
||||||
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
|
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. The image
|
||||||
|
# ships git and make, which the version step below uses.
|
||||||
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
|
||||||
|
|
||||||
# Depend on lint stage passing
|
# 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=lint /src/go.sum /dev/null
|
||||||
|
COPY --from=test /src/go.sum /dev/null
|
||||||
# jq is a runtime dependency of script/ci-mark-superseded, which the test
|
COPY --from=css-check /out/tailwind.css /dev/null
|
||||||
# suite executes. git is what script/version derives the version with.
|
COPY --from=markdown-check /src/yarn.lock /dev/null
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/*
|
|
||||||
|
|
||||||
# A build context sent as a tar archive keeps its files' owners, and git
|
# 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
|
# refuses to read a checkout owned by another user. Trust this one
|
||||||
@@ -52,31 +191,27 @@ WORKDIR /build
|
|||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
# Copy source code, including the .ci-fingerprint cache barrier described in
|
|
||||||
# the lint stage above.
|
|
||||||
COPY . .
|
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
|
# Version stamped into the binary: the VERSION build arg when one is
|
||||||
# given, otherwise what script/version derives from the .git the build
|
# given, otherwise what script/version derives from the .git the build
|
||||||
# context carries, so any `docker build .` of a clone stamps its commit.
|
# context carries, so any `docker build .` of a clone stamps its commit.
|
||||||
# With neither, as from a source tarball, it is "unknown".
|
# 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
|
ARG VERSION
|
||||||
|
|
||||||
# A context that carries .git must not stamp "unknown": that means git is
|
# A context that carries .git must not stamp an empty version, "dev" or
|
||||||
# missing here or could not read the checkout, and the image could not be
|
# "unknown": that means git is missing here or could not read the
|
||||||
# traced back to its commit.
|
# checkout, and the image could not be traced back to its commit.
|
||||||
RUN if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
|
RUN version="$(make version VERSION="$VERSION")"; \
|
||||||
echo "version is unknown although the build context carries .git" >&2; \
|
if [ -e .git ]; then \
|
||||||
exit 1; \
|
case "$version" in ""|dev|unknown) \
|
||||||
|
echo "version is '$version' although .git is present" >&2; \
|
||||||
|
exit 1 ;; \
|
||||||
|
esac; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Builds through the Makefile's build target, which runs script/assets
|
||||||
|
# (Alpine.js, extracted from its tarball in 3p/) first.
|
||||||
RUN make build VERSION="$VERSION"
|
RUN make build VERSION="$VERSION"
|
||||||
|
|
||||||
# Rebuild with static linking for Alpine runtime.
|
# Rebuild with static linking for Alpine runtime.
|
||||||
|
|||||||
+1
-1
@@ -16,7 +16,7 @@ COPY . .
|
|||||||
|
|
||||||
# The test binary embeds the templates and static files, so the browser
|
# 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
|
# 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
|
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
|
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
|
||||||
|
|||||||
@@ -1,39 +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 . .
|
|
||||||
|
|
||||||
# `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 ./...
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css version
|
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
.DEFAULT_GOAL := check
|
.DEFAULT_GOAL := check
|
||||||
@@ -19,6 +19,10 @@ override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
|
|||||||
# Extra linker flags for the build target. The static relink in the
|
# Extra linker flags for the build target. The static relink in the
|
||||||
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
|
||||||
# so composing flags cannot drop the version stamp.
|
# so composing flags cannot drop the version stamp.
|
||||||
|
#
|
||||||
|
# The build target itself always passes -trimpath and -s -w, as the Go
|
||||||
|
# Dockerfile in REPO_POLICIES.md does: no build paths, symbol table or
|
||||||
|
# debug information in the binary.
|
||||||
GO_LDFLAGS ?=
|
GO_LDFLAGS ?=
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@@ -49,7 +53,7 @@ check:
|
|||||||
@script/check
|
@script/check
|
||||||
|
|
||||||
build: assets
|
build: assets
|
||||||
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
go build -trimpath -ldflags '$(strip -s -w -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker
|
||||||
|
|
||||||
run: build
|
run: build
|
||||||
./bin/webhooker
|
./bin/webhooker
|
||||||
@@ -74,4 +78,7 @@ hooks:
|
|||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
css:
|
css:
|
||||||
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
|
@script/css
|
||||||
|
|
||||||
|
css-check:
|
||||||
|
@script/css-check
|
||||||
|
|||||||
+351
-88
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
title: Repository Policies
|
title: Repository Policies
|
||||||
last_modified: 2026-08-07
|
last_modified: 2026-10-04
|
||||||
---
|
---
|
||||||
|
|
||||||
This document covers repository structure, tooling, and workflow standards. Code
|
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
|
prerequisite since nvm requires bash. yarn is then pinned via
|
||||||
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
|
||||||
always exact versions. `script/cibuild` runs the CI build: it changes to the
|
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
|
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
|
||||||
scripts are our own extensions to the standard: `script/check` runs
|
with the version; the Gitea workflow calls it. **`script/cibuild` runs
|
||||||
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
|
`script/bootstrap` first**, because the workflow checks out the repo and runs
|
||||||
what the git pre-commit hook runs, and it calls `script/check`;
|
nothing else, while `script/fmt-check` runs the formatter on the host: on a
|
||||||
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
|
pristine checkout with nothing installed the run dies there, after the
|
||||||
target shims to it); and `script/projectname` (literally that filename) simply
|
containerised gates have passed. **The bootstrap alone is not enough**:
|
||||||
outputs the project's name. Scripts that need the name call
|
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
|
||||||
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
|
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
|
||||||
so those scripts stay byte-identical across all repos. Repo-type-specific
|
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
|
||||||
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
|
source nvm for the pinned node version before invoking it, exactly as
|
||||||
`script/precommit`, not in the hook itself. Model scripts are at
|
`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
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
|
||||||
must document the provided scripts in an **Entrypoints** section (see the
|
must document the provided scripts in an **Entrypoints** section (see the
|
||||||
README requirements below).
|
README requirements below).
|
||||||
@@ -89,87 +100,198 @@ style conventions are in separate documents:
|
|||||||
contributor should be able to understand the entire development workflow by
|
contributor should be able to understand the entire development workflow by
|
||||||
reading the Makefile.
|
reading the Makefile.
|
||||||
|
|
||||||
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
|
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
|
||||||
as a build step so the build fails if the branch is not green. For non-server
|
`lint` phase and a `test` phase, with the final stage depending on both so the
|
||||||
repos, the Dockerfile should bring up a development environment and run
|
image cannot be built unless they pass. For non-server repos the final stage
|
||||||
`make check`. For server repos, `make check` should run as an early build
|
brings up a development environment; for server repos it is the runtime image.
|
||||||
stage before the final image is assembled. Dockerfiles install development
|
The gate phases and the build stage start from their pinned base images and
|
||||||
prerequisites by running `script/bootstrap` rather than duplicating installs
|
install what those images lack either inline, as the canonical Go `Dockerfile`
|
||||||
inline; COPY `script/` and the dependency manifests (`package.json` +
|
below does for `git`, or by running `script/bootstrap`, as the `prompts`
|
||||||
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
|
repo's own `Dockerfile` does for its yarn packages. The development
|
||||||
layer stays cached until dependencies change.
|
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
|
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
|
||||||
repos use a multistage build where linting runs in an independent stage based
|
no separate lint file. `script/lint` and `script/test` each build one phase
|
||||||
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
|
and nothing else:
|
||||||
`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.
|
|
||||||
|
|
||||||
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
|
```dockerfile
|
||||||
# Lint stage — fast feedback on formatting and lint issues
|
# Lint phase
|
||||||
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
FROM golangci/golangci-lint@sha256:... AS lint
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN make fmt-check
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
RUN make lint
|
|
||||||
|
|
||||||
# Build stage
|
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
|
||||||
# golang:1.x-alpine, YYYY-MM-DD
|
# image ships and the alpine one does not.
|
||||||
FROM golang@sha256:... AS builder
|
# golang:1.x, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS test
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
# Force BuildKit to run the lint stage before proceeding
|
|
||||||
COPY --from=lint /src/go.sum /dev/null
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
COPY . .
|
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
|
# Build stage. Nothing is wanted from either phase above; the copies
|
||||||
RUN CGO_ENABLED=0 go build -trimpath \
|
# 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 . .
|
||||||
|
|
||||||
|
# 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}" \
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
-o /app ./cmd/app/
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
# Runtime stage
|
# Runtime stage, and the last one
|
||||||
FROM alpine@sha256:...
|
FROM alpine@sha256:...
|
||||||
COPY --from=builder /app /usr/local/bin/app
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
ENTRYPOINT ["app"]
|
ENTRYPOINT ["app"]
|
||||||
```
|
```
|
||||||
|
|
||||||
Key points:
|
Key points:
|
||||||
- The lint stage uses the `golangci/golangci-lint` image directly (it
|
- The lint phase uses the `golangci/golangci-lint` image directly (it has
|
||||||
includes both Go and the linter), so there is no need to install the
|
both Go and the linter), so nothing needs installing.
|
||||||
linter separately.
|
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
|
||||||
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
|
purpose is the ordering edge. BuildKit runs stages in parallel by default,
|
||||||
a stage dependency. BuildKit runs stages in parallel by default; without
|
and a stage nothing depends on is not built at all, so without these two
|
||||||
this line, the build stage would not wait for lint to finish and a lint
|
lines a red gate would not fail the build.
|
||||||
failure might not fail the overall 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
|
- 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:
|
create placeholder files so the embed directives resolve. Example:
|
||||||
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
`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
|
- If the project requires CGO or system libraries for linting, install them
|
||||||
fail fast.
|
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
|
||||||
- If the project requires CGO or system libraries for linting (e.g.
|
has no `apk`, so install with `apt-get` under the Debian package name
|
||||||
`vips-dev`), install them in the lint stage with `apk add`.
|
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
|
||||||
- The build stage runs `make test` after compilation setup. Tests run in the
|
lists in the same `RUN`, so the layer does not keep them:
|
||||||
build stage, not the lint stage, because they may require compiled
|
|
||||||
artifacts or heavier dependencies.
|
```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
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
|
||||||
runs `script/cibuild` (which runs `docker build .`) on push. Since the
|
runs `script/cibuild` on push, and checks out the repo as its only other step.
|
||||||
Dockerfile already runs `make check`, a successful build implies all checks
|
That script bootstraps, runs the gate phases, and then builds the image, so a
|
||||||
pass.
|
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
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
|
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
|
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
|
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
|
improvement bug against that repo. Add a 90-second timeout to the test
|
||||||
invocation in the Makefile (`go test -timeout 90s`). The backstop deliberately
|
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
|
||||||
sits above the hard cap so that it catches a genuinely hung test rather than a
|
hard cap so that it catches a genuinely hung test rather than a merely slow
|
||||||
merely slow one.
|
one.
|
||||||
|
|
||||||
- **`make test` should use the conditional verbose rerun pattern.** Run tests
|
- **The test command should use the conditional verbose rerun pattern.** Run
|
||||||
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
|
tests without `-v` (verbose) first. If tests fail, automatically rerun with
|
||||||
show full output. This keeps CI logs and `docker build` output clean on
|
`-v` to show full output. This keeps CI logs and `docker build` output clean
|
||||||
success (just package/suite summaries) while providing full diagnostic detail
|
on success (just package/suite summaries) while providing full diagnostic
|
||||||
on failure (every test case, every assertion). The general shell pattern:
|
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
|
```makefile
|
||||||
test:
|
test:
|
||||||
@@ -214,11 +338,26 @@ style conventions are in separate documents:
|
|||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
test:
|
test:
|
||||||
@go test -timeout 90s -race -cover ./... || \
|
@go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
{ echo "--- Rerunning with -v for details ---"; \
|
{ 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:
|
Python example:
|
||||||
|
|
||||||
```makefile
|
```makefile
|
||||||
@@ -244,10 +383,84 @@ style conventions are in separate documents:
|
|||||||
must be in `.gitignore`. No exceptions.
|
must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
|
||||||
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
|
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
|
||||||
Fetch the standard `.gitignore` from
|
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
|
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
a new repo.
|
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
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
bundles, minified output, generated assets) must never be committed to the
|
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
|
- Make all changes on a feature branch. You can do whatever you want on a
|
||||||
feature branch.
|
feature branch.
|
||||||
|
|
||||||
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
|
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
|
||||||
manually by the user. Fetch from
|
_NEVER_ be modified by an agent: fetch it from
|
||||||
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
|
||||||
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
|
byte-identical, so that no repo can quietly loosen its own linting. Linter
|
||||||
commit-pinned via
|
configuration changes are made to the canonical copy in the `prompts` repo and
|
||||||
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
|
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
|
- When pinning images or packages by hash, add a comment above the reference
|
||||||
with the version and date (YYYY-MM-DD).
|
with the version and date (YYYY-MM-DD).
|
||||||
@@ -382,12 +639,14 @@ style conventions are in separate documents:
|
|||||||
settings.
|
settings.
|
||||||
|
|
||||||
- Avoid putting files in the repo root unless necessary. Root should contain
|
- Avoid putting files in the repo root unless necessary. Root should contain
|
||||||
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
|
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
|
||||||
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
|
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
|
||||||
language-specific config). Everything else goes in a subdirectory. Canonical
|
and language-specific config). Everything else goes in a subdirectory.
|
||||||
subdirectory names:
|
Canonical subdirectory names:
|
||||||
- `bin/` — executable scripts and tools
|
- `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
|
- `configs/` — configuration templates and examples
|
||||||
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
- `docs/` — documentation and markdown (README.md stays in root)
|
- `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`
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
- Python: `pyproject.toml`
|
- 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,403 +2,367 @@
|
|||||||
|
|
||||||
One issue per unit of work, one branch and one PR per issue:
|
One issue per unit of work, one branch and one PR per issue:
|
||||||
|
|
||||||
* ensure a tracked issue exists with a definition of done
|
- ensure a tracked issue exists with a definition of done
|
||||||
* branch from `next` (never from `main`)
|
- branch from `next` (never from `main`)
|
||||||
* do the work; open a PR based on `next` (never on `main`)
|
- do the work; open a PR based on `next` (never on `main`)
|
||||||
* pass an independent review, then the manager squash-merges into `next`
|
- pass an independent review, then the manager squash-merges into `next`
|
||||||
* push; nothing stays local-only
|
- push; nothing stays local-only
|
||||||
|
|
||||||
`next` is the branch for the next milestone and must stay green and
|
`next` is the branch for the next milestone and must stay green and mergeable to
|
||||||
mergeable to `main` without notice. One `next` -> `main` PR accumulates
|
`main` without notice. One `next` -> `main` PR accumulates the milestone;
|
||||||
the milestone; releases are cut from `main` separately.
|
releases are cut from `main` separately.
|
||||||
|
|
||||||
Issue branches do NOT touch this file — the manager maintains it on
|
Issue branches do NOT touch this file — the manager maintains it on `next`.
|
||||||
`next`. Every branch editing `TODO.md` conflicts with every other
|
Every branch editing `TODO.md` conflicts with every other (#112).
|
||||||
(#112).
|
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the
|
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
|
authoritative list, and the only place to read a count or a state of play from.
|
||||||
play from. This file records where the project is, not what is in
|
This file records where the project is, not what is in flight: a sentence whose
|
||||||
flight: a sentence whose truth depends on a branch being unmerged is
|
truth depends on a branch being unmerged is wrong the moment it merges, and this
|
||||||
wrong the moment it merges, and this file has been wrong that way
|
file has been wrong that way before.
|
||||||
before.
|
|
||||||
|
|
||||||
The durability defect that held the tag has landed
|
The durability defect that held the tag has landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`).
|
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite
|
||||||
Every SQLite handle opens with WAL journaling and a busy timeout, a
|
handle opens with WAL journaling and a busy timeout, a bookkeeping write that
|
||||||
bookkeeping write that fails leaves its delivery in a recoverable
|
fails leaves its delivery in a recoverable state rather than a lying one, and
|
||||||
state rather than a lying one, and recovery skips a delivery that
|
recovery skips a delivery that already has a successful result row. Final
|
||||||
already has a successful result row. Final pre-tag verification
|
pre-tag verification exercised it and confirmed it holds. Whatever the milestone
|
||||||
exercised it and confirmed it holds. Whatever the milestone still
|
still shows open is what remains before `v1.0.0`.
|
||||||
shows open is what remains before `v1.0.0`.
|
|
||||||
|
|
||||||
Delivery is at-least-once by design, not by accident: a send whose
|
Delivery is at-least-once by design, not by accident: a send whose result row
|
||||||
result row does not land is attempted again, so a receiver can see a
|
does not land is attempted again, so a receiver can see a duplicate. That is
|
||||||
duplicate. That is deliberate — the alternative is a silent lost
|
deliberate — the alternative is a silent lost delivery — and the README says so
|
||||||
delivery — and the README says so under Rationale. It is not a defect
|
under Rationale. It is not a defect to re-file.
|
||||||
to re-file.
|
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Clear the rest of the open 1.0.0 milestone
|
Clear the rest of the open 1.0.0 milestone
|
||||||
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`.
|
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
|
||||||
Merging `next` into `main` is a separate act from tagging and waits on
|
`next` into `main` is a separate act from tagging and waits on neither of those:
|
||||||
neither of those: `next` is kept mergeable at all times, which is the
|
`next` is kept mergeable at all times, which is the point of the branch.
|
||||||
point of the branch.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via
|
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS`
|
||||||
`BIND_ADDRESS` defaulting to `127.0.0.1`, and document the
|
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A
|
||||||
reverse-proxy deployment. A hostname, an empty value or a value
|
hostname, an empty value or a value carrying a port is a startup error, and
|
||||||
carrying a port is a startup error, and the `Dockerfile` sets
|
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is
|
||||||
`0.0.0.0` because a loopback bind inside a container is unreachable
|
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/268). The same commit
|
removed the shutdown race: `httpServer` is built in the constructor rather
|
||||||
removed the shutdown race: `httpServer` is built in the constructor
|
than assigned from the serving goroutine, which orders the write before every
|
||||||
rather than assigned from the serving goroutine, which orders the
|
fx hook and rules out the nil dereference a SIGTERM arriving first would have
|
||||||
write before every fx hook and rules out the nil dereference a
|
caused, and `sentryEnabled` is an `atomic.Bool`
|
||||||
SIGTERM arriving first would have caused, and `sentryEnabled` is an
|
(https://git.eeqj.de/sneak/webhooker/issues/226)
|
||||||
`atomic.Bool` (https://git.eeqj.de/sneak/webhooker/issues/226)
|
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
|
||||||
- 2026-08-24 Remove inbound request signature verification. The
|
is the authentication secret, so the per-entrypoint shared secret, the
|
||||||
entrypoint UUID is the authentication secret, so the per-entrypoint
|
`internal/signature` package, the receiver check, the model fields and the
|
||||||
shared secret, the `internal/signature` package, the receiver check,
|
forms are all gone. This reverses the feature that landed earlier in the same
|
||||||
the model fields and the forms are all gone. This reverses the
|
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
|
||||||
feature that landed earlier in the same milestone
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/67,
|
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/279)
|
https://git.eeqj.de/sneak/webhooker/issues/279)
|
||||||
- 2026-08-24 Stamp the build version into the binary and render it in
|
- 2026-08-24 Stamp the build version into the binary and render it in the UI
|
||||||
the UI footer. `script/version` is the single source — `$VERSION`,
|
footer. `script/version` is the single source — `$VERSION`, else
|
||||||
else `git describe --tags --always --dirty`, else `unknown` — so a
|
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
|
||||||
`make build` binary and a `make docker` image from one checkout
|
binary and a `make docker` image from one checkout report the same thing, and
|
||||||
report the same thing, and nothing in it varies between two builds
|
nothing in it varies between two builds of the same commit, which the release
|
||||||
of the same commit, which the release gate's byte-identical
|
gate's byte-identical assertion would catch
|
||||||
assertion would catch
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
(https://git.eeqj.de/sneak/webhooker/issues/253)
|
||||||
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the
|
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
|
||||||
request transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a
|
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
|
||||||
real TLS proxy with the environment left at its `dev` default, the
|
with the environment left at its `dev` default, the session cookie silently
|
||||||
session cookie silently lost `Secure` while the CSRF cookie on the
|
lost `Secure` while the CSRF cookie on the same response kept it.
|
||||||
same response kept it. `X-Forwarded-Proto` is now matched
|
`X-Forwarded-Proto` is now matched case-insensitively on its first
|
||||||
case-insensitively on its first comma-separated element, so `HTTPS`
|
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
|
||||||
and `https, http` no longer fall to the relaxed CSRF path
|
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/269)
|
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
|
||||||
- 2026-08-24 Roll back a failed webhook deletion instead of committing
|
failing delete committed whatever had already succeeded, hard-deleted the
|
||||||
it. A failing delete committed whatever had already succeeded,
|
per-webhook event database anyway, and redirected as though it had worked —
|
||||||
hard-deleted the per-webhook event database anyway, and redirected as
|
orphaned config plus permanently destroyed history, reported as success. All
|
||||||
though it had worked — orphaned config plus permanently destroyed
|
three delete positions now roll back with the event database intact
|
||||||
history, reported as success. All three delete positions now roll
|
|
||||||
back with the event database intact
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
(https://git.eeqj.de/sneak/webhooker/issues/262)
|
||||||
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
- 2026-08-24 Name a deleted target on its historical deliveries, marked
|
||||||
`(deleted)`, rather than leaving the event log unable to say where a
|
`(deleted)`, rather than leaving the event log unable to say where a delivery
|
||||||
delivery went. A deleted target's credentials stay masked exactly as
|
went. A deleted target's credentials stay masked exactly as a live one's, and
|
||||||
a live one's, and it cannot become deliverable again through the
|
it cannot become deliverable again through the receiver, resubmit, replay, the
|
||||||
receiver, resubmit, replay, the edit form or the toggle
|
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/211)
|
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
|
||||||
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions,
|
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
|
||||||
so the unauthenticated receiver is no longer a memory-exhaustion
|
carries the chi route pattern, and `method` folds anything chi cannot route
|
||||||
vector: `handler` carries the chi route pattern, and `method` folds
|
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
|
||||||
anything chi cannot route onto a single `(unmatched)` sentinel. Both
|
300 random method tokens took the series count from 106 to 7,631, and path
|
||||||
were reproduced before the fix — 300 random method tokens took the
|
flooding reached 62,532 — and a label audit across a live scrape found no
|
||||||
series count from 106 to 7,631, and path flooding reached 62,532 —
|
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
|
||||||
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)
|
https://git.eeqj.de/sneak/webhooker/issues/261)
|
||||||
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7`
|
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
|
||||||
and `-5` silently became 0 — fire-and-forget — including on the edit
|
silently became 0 — fire-and-forget — including on the edit path, where it
|
||||||
path, where it destroyed a working value, and `999999999` stored
|
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
|
||||||
verbatim. The ceiling of 20 is the `max` both templates already
|
is the `max` both templates already declared
|
||||||
declared (https://git.eeqj.de/sneak/webhooker/issues/221)
|
(https://git.eeqj.de/sneak/webhooker/issues/221)
|
||||||
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a
|
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
|
||||||
backend under development can be tested against real captured
|
under development can be tested against real captured traffic. Per-delivery
|
||||||
traffic. Per-delivery replay cannot serve that: it re-sends one
|
replay cannot serve that: it re-sends one finished delivery to its own
|
||||||
finished delivery to its own original target, and a target created
|
original target, and a target created for a dev backend has no prior delivery
|
||||||
for a dev backend has no prior delivery to replay. Resubmit
|
to replay. Resubmit re-injects the stored event at the top of the receiver
|
||||||
re-injects the stored event at the top of the receiver path and fans
|
path and fans it out to whatever targets are active now
|
||||||
it out to whatever targets are active now
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
(https://git.eeqj.de/sneak/webhooker/issues/250)
|
||||||
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two
|
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
|
||||||
instances on one directory cannot both deliver
|
on one directory cannot both deliver
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
(https://git.eeqj.de/sneak/webhooker/issues/201)
|
||||||
- 2026-08-20 Shut down the app when the HTTP listener fails. The
|
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
|
||||||
`OnStart` hook returned as soon as the serving goroutine was
|
returned as soon as the serving goroutine was spawned, so a failed listen left
|
||||||
spawned, so a failed listen left fx reporting RUNNING and a live
|
fx reporting RUNNING and a live process with nothing bound — invisible to
|
||||||
process with nothing bound — invisible to systemd and Docker restart
|
systemd and Docker restart policies
|
||||||
policies (https://git.eeqj.de/sneak/webhooker/issues/200)
|
(https://git.eeqj.de/sneak/webhooker/issues/200)
|
||||||
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
- 2026-08-20 Stop target credentials leaking into the per-webhook event
|
||||||
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL
|
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
|
||||||
with placeholders rather than bound values
|
placeholders rather than bound values
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on
|
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
|
||||||
half-set metrics auth credentials
|
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/205)
|
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
|
||||||
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps
|
trace recorder in for the logging adapter, and that recorder does not
|
||||||
GORM's own trace recorder in for the logging adapter, and that
|
implement `gorm.ParamsFilter`, so those statements logged their bound values
|
||||||
recorder does not implement `gorm.ParamsFilter`, so those statements
|
interpolated and bypassed the suppression above. The two units gated green
|
||||||
logged their bound values interpolated and bypassed the suppression
|
against a `next` that lacked the other, and `next` went red when both landed
|
||||||
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)
|
(https://git.eeqj.de/sneak/webhooker/issues/234)
|
||||||
- 2026-08-20 Render per-attempt delivery detail in the event log
|
- 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
|
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
|
||||||
terminally failed delivery
|
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/203)
|
|
||||||
- 2026-08-20 Expose delivery metrics on `/metrics`
|
- 2026-08-20 Expose delivery metrics on `/metrics`
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the
|
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
|
||||||
backup, restore and upgrade procedures
|
restore and upgrade procedures
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
(https://git.eeqj.de/sneak/webhooker/issues/210)
|
||||||
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap
|
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
|
||||||
banner. The admin bootstrap password was printed once among roughly
|
admin bootstrap password was printed once among roughly 45 fx lines, and under
|
||||||
45 fx lines, and under `docker run -d` went to container logs subject
|
`docker run -d` went to container logs subject to rotation; there was no reset
|
||||||
to rotation; there was no reset path at all, so recovery meant
|
path at all, so recovery meant hand-deleting the users row, documented
|
||||||
hand-deleting the users row, documented nowhere. The password is read
|
nowhere. The password is read from stdin or generated, never from argv where
|
||||||
from stdin or generated, never from argv where `/proc` would publish
|
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
it (https://git.eeqj.de/sneak/webhooker/issues/208)
|
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
|
||||||
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch
|
SSRF guard, so a self-hosted proxy can forward into the operator's own
|
||||||
for the SSRF guard, so a self-hosted proxy can forward into the
|
network. The guard's always-blocked set cannot be reopened by configuration
|
||||||
operator's own network. The guard's always-blocked set cannot be
|
|
||||||
reopened by configuration
|
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
(https://git.eeqj.de/sneak/webhooker/issues/204)
|
||||||
- 2026-08-20 Harden operator-set target headers, which were carried
|
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
|
||||||
unsafely across a redirect
|
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/233)
|
|
||||||
- 2026-08-20 Add a target edit form with headers and timeout fields
|
- 2026-08-20 Add a target edit form with headers and timeout fields
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
(https://git.eeqj.de/sneak/webhooker/issues/127)
|
||||||
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s,
|
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
|
||||||
matching the org-wide backstop. `go test` applies `-timeout` per
|
the org-wide backstop. `go test` applies `-timeout` per package, and
|
||||||
package, and `internal/handlers` had grown past the old budget: a
|
`internal/handlers` had grown past the old budget: a cache-defeated build
|
||||||
cache-defeated build failed outright at `GOMAXPROCS=4`, and every run
|
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
|
||||||
under deliberate host load breached 30s. The measurement table lives
|
breached 30s. The measurement table lives in the script (#194)
|
||||||
in the script (#194)
|
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
|
||||||
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy
|
and still mandated a 20s test target with a 30s timeout, which the org
|
||||||
was stale and still mandated a 20s test target with a 30s timeout,
|
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
|
||||||
which the org replaced with a 60s cap and a 90s backstop. A synced
|
reading it as one nearly produced a PR against `prompts` proposing a change
|
||||||
copy is not a source; reading it as one nearly produced a PR against
|
already merged there (#196)
|
||||||
`prompts` proposing a change already merged there (#196)
|
- 2026-08-18 Report handler panics through the logger and answer 500. chi
|
||||||
- 2026-08-18 Report handler panics through the logger and answer 500.
|
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
|
||||||
chi v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no
|
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
|
||||||
longer emits, then indexes `pkg[-1:]`, so it panicked inside its own
|
writing a byte: the recovery never ran, the client got a dropped connection
|
||||||
stack printer before writing a byte: the recovery never ran, the
|
instead of a 500, and the original panic was lost. A local middleware replaces
|
||||||
client got a dropped connection instead of a 500, and the original
|
it, bounded by `MaxPanicLogLineBytes` (#187)
|
||||||
panic was lost. A local middleware replaces it, bounded by
|
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
|
||||||
`MaxPanicLogLineBytes` (#187)
|
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
|
||||||
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every
|
false, so **every record-not-found printed the fully interpolated SQL to
|
||||||
`gorm.Open` left `logger.Default` in place at `Warn` with
|
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
|
||||||
`IgnoreRecordNotFoundError` false, so **every record-not-found
|
submitted username on the login form, at no level the operator set and outside
|
||||||
printed the fully interpolated SQL to stdout** — including the
|
`internal/logger` entirely. Three call sites, not the two the issue named
|
||||||
client-chosen path on `/webhook/{uuid}` and the submitted username on
|
(#178)
|
||||||
the login form, at no level the operator set and outside
|
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
|
||||||
`internal/logger` entirely. Three call sites, not the two the issue
|
reachable unauthenticated, found by reading every `slog` call in the tree
|
||||||
named (#178)
|
rather than only the one reported; the budget moved to a shared
|
||||||
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight
|
`internal/logfield` so no second truncation exists. `DEBUG` being off by
|
||||||
sites reachable unauthenticated, found by reading every `slog` call in
|
default is not a bound and is not treated as one (#176)
|
||||||
the tree rather than only the one reported; the budget moved to a
|
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
|
||||||
shared `internal/logfield` so no second truncation exists. `DEBUG`
|
non-fatal `assert` on an acquire result was dereferenced on the next line, so
|
||||||
being off by default is not a bound and is not treated as one (#176)
|
one timing miss killed the whole `internal/middleware` binary and reddened CI
|
||||||
- 2026-08-18 Stop a slow host turning a login-guard test into a
|
for unrelated PRs. The fix also removed a real production race — `acquire`
|
||||||
segfault. A non-fatal `assert` on an acquire result was dereferenced
|
could shed a request with a slot standing free, because Go picks uniformly
|
||||||
on the next line, so one timing miss killed the whole
|
among ready `select` cases (#186)
|
||||||
`internal/middleware` binary and reddened CI for unrelated PRs. The
|
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
|
||||||
fix also removed a real production race — `acquire` could shed a
|
The receiver's path carries the entrypoint capability token, so every Sentry
|
||||||
request with a slot standing free, because Go picks uniformly among
|
event from `/webhook/{uuid}` shipped a live credential to a third party.
|
||||||
ready `select` cases (#186)
|
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
|
||||||
- 2026-08-18 Send the chi route pattern to Sentry rather than the
|
reduced to an allowlist (#179)
|
||||||
concrete path. The receiver's path carries the entrypoint capability
|
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
|
||||||
token, so every Sentry event from `/webhook/{uuid}` shipped a live
|
query string, so a login could be driven by URL parameters — putting the
|
||||||
credential to a third party. Request `Data`, `QueryString`, `Cookies`
|
password somewhere that lands in access logs, proxy logs and browser history
|
||||||
and `Env` are dropped and headers reduced to an allowlist (#179)
|
(#160)
|
||||||
- 2026-08-18 Read form fields from the POST body only. `r.FormValue`
|
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
|
||||||
merges the query string, so a login could be driven by URL parameters
|
flood of wrong passwords cannot lock out the account it is guessing at. The
|
||||||
— putting the password somewhere that lands in access logs, proxy
|
manager took this decision rather than stall the queue; it is flagged on the
|
||||||
logs and browser history (#160)
|
issue for reversal (#150)
|
||||||
- 2026-08-18 Verify login credentials before spending rate-limit
|
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
|
||||||
budget, so a flood of wrong passwords cannot lock out the account it
|
wrong in both directions from version skew and shared caches. `script/lint`
|
||||||
is guessing at. The manager took this decision rather than stall the
|
asserts the summary line, because `--no-cache-filter` silently ignores a stage
|
||||||
queue; it is flagged on the issue for reversal (#150)
|
name it does not match — the flag that makes the gate meaningful fails open
|
||||||
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint
|
(#109)
|
||||||
was wrong in both directions from version skew and shared caches.
|
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
|
||||||
`script/lint` asserts the summary line, because `--no-cache-filter`
|
truncates for rendering, and that truncated value was the only way to read a
|
||||||
silently ignores a stage name it does not match — the flag that makes
|
body, so the full payload was unreachable (#157)
|
||||||
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.
|
- 2026-08-18 Bound the access log line against client-chosen text.
|
||||||
`internal/logfield` budgets by *encoded* bytes, not runes, so a
|
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
|
||||||
handler's JSON escaping cannot multiply a field past its allowance
|
escaping cannot multiply a field past its allowance (#146)
|
||||||
(#146)
|
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
|
||||||
- 2026-08-18 Mark superseded CI commits `failure` rather than
|
skipped run rolls up green, so a commit that was never tested reported success
|
||||||
`skipped`. A skipped run rolls up green, so a commit that was never
|
(#152)
|
||||||
tested reported success (#152)
|
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
|
||||||
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so
|
hooks are bounded by a deadline the orchestrator will actually honour rather
|
||||||
shutdown hooks are bounded by a deadline the orchestrator will
|
than being killed mid-flush (#134)
|
||||||
actually honour rather than being killed mid-flush (#134)
|
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
|
||||||
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation
|
2^64 addresses, so per-address keying let one client mint unlimited buckets.
|
||||||
hands out 2^64 addresses, so per-address keying let one client mint
|
Manager decision, recorded on the issue (#125)
|
||||||
unlimited buckets. Manager decision, recorded on the issue (#125)
|
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
|
||||||
- 2026-08-17 Correct release-blocking README and startup-warning
|
including claims about behaviour the code does not have (#151)
|
||||||
inaccuracies, including claims about behaviour the code does not have
|
|
||||||
(#151)
|
|
||||||
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
- 2026-08-17 Fetch and verify Alpine.js at build time against
|
||||||
`static/vendor.sha256` instead of committing the minified blob, so
|
`static/vendor.sha256` instead of committing the minified blob, so the
|
||||||
the dependency is pinned by hash rather than by trust (#145)
|
dependency is pinned by hash rather than by trust (#145)
|
||||||
- 2026-08-17 Bound the event log's rendered bodies in the query itself,
|
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
|
||||||
so a large stored payload cannot be read into memory just to be
|
large stored payload cannot be read into memory just to be truncated for
|
||||||
truncated for display (#135)
|
display (#135)
|
||||||
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can
|
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
|
||||||
carry a bearer credential in its path or query, and was rendered
|
bearer credential in its path or query, and was rendered verbatim. Manager
|
||||||
verbatim. Manager decision to mask unconditionally (#115)
|
decision to mask unconditionally (#115)
|
||||||
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that
|
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
|
||||||
hangs cannot hold the process past its grace period (#102)
|
cannot hold the process past its grace period (#102)
|
||||||
- 2026-08-14 Render templates via a buffer rather than the
|
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
|
||||||
`ResponseWriter`, so a template error part-way through cannot commit
|
a template error part-way through cannot commit a 200 and then fail — the
|
||||||
a 200 and then fail — the response is written only once it is whole
|
response is written only once it is whole (#123)
|
||||||
(#123)
|
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
|
||||||
- 2026-08-14 Align the session codec's max-age with the 7-day absolute
|
codec accepted cookies the session layer considered expired, so the cap was
|
||||||
cap. The codec accepted cookies the session layer considered expired,
|
enforced in one place and not the other (#108)
|
||||||
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
|
||||||
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where
|
default silently discards forwarded headers and every client rate-limits as
|
||||||
the safe default silently discards forwarded headers and every client
|
the proxy's address (#149)
|
||||||
rate-limits as the proxy's address (#149)
|
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
|
||||||
- 2026-08-12 Bound the receiver rate limit per client IP across the
|
`/webhook/*` route. The existing limiter keyed on the request path and
|
||||||
whole `/webhook/*` route. The existing limiter keyed on the request
|
`/webhook/{uuid}` matches any single segment, so a client that invented a
|
||||||
path and `/webhook/{uuid}` matches any single segment, so a client
|
fresh path per request minted a fresh bucket per request: the limit on the
|
||||||
that invented a fresh path per request minted a fresh bucket per
|
only unauthenticated endpoint bounded nothing in aggregate, and every request
|
||||||
request: the limit on the only unauthenticated endpoint bounded
|
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
|
||||||
nothing in aggregate, and every request still cost an entrypoint
|
client address alone now bounds that, chained in front of the unchanged
|
||||||
lookup before it 404ed. An outer limiter keyed on the client address
|
|
||||||
alone now bounds that, chained in front of the unchanged
|
|
||||||
per-entrypoint limiter (#139)
|
per-entrypoint limiter (#139)
|
||||||
- 2026-08-12 Correct release-blocking documentation inaccuracies: the
|
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README
|
||||||
README promised manual redelivery in the present tense in three
|
promised manual redelivery in the present tense in three places when nothing
|
||||||
places when nothing implements it (the same false claim also sat in
|
implements it (the same false claim also sat in the doc comment that was its
|
||||||
the doc comment that was its source text), the env table omitted
|
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md`
|
||||||
`RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
|
itself omitted five landed units (#141)
|
||||||
units (#141)
|
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now
|
||||||
- 2026-08-12 Make the CI gate execute the checks it reports on. The
|
writes a build-context fingerprint before calling `script/cibuild`, so a code
|
||||||
workflow now writes a build-context fingerprint before calling
|
commit invalidates the `COPY` layer of the lint and builder stages while a
|
||||||
`script/cibuild`, so a code commit invalidates the `COPY` layer of
|
docs-only commit still replays from cache; a superseding run also rewrites the
|
||||||
the lint and builder stages while a docs-only commit still replays
|
`failure` status Gitea leaves on commits it cancelled and never tested.
|
||||||
from cache; a superseding run also rewrites the `failure` status
|
Verified by pushing a deliberately broken test and watching CI go red (#119)
|
||||||
Gitea leaves on commits it cancelled and never tested. Verified by
|
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value
|
||||||
pushing a deliberately broken test and watching CI go red (#119)
|
reached `time.NewTicker` in both the retention reaper and the archive sweeper,
|
||||||
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
|
panicking two goroutines with no recover after startup had already reported
|
||||||
non-positive value reached `time.NewTicker` in both the retention
|
success (#140)
|
||||||
reaper and the archive sweeper, panicking two goroutines with no
|
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the
|
||||||
recover after startup had already reported success (#140)
|
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and
|
||||||
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
|
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request
|
||||||
cap: the reverse walk cuts entries with `strings.LastIndexByte`
|
on the unauthenticated receiver. Semantics proven unchanged by differential
|
||||||
instead of joining and splitting, so a 1 MB header allocates 16 bytes
|
testing against the previous implementation (#133)
|
||||||
rather than 1.6 MB per request on the unauthenticated receiver.
|
|
||||||
Semantics proven unchanged by differential testing against the
|
|
||||||
previous implementation (#133)
|
|
||||||
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
|
||||||
attacker-supplied chain cannot burn unbounded CPU in the rate-limit
|
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key
|
||||||
key function; running off the end falls back to the peer address
|
function; running off the end falls back to the peer address (#124)
|
||||||
(#124)
|
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list:
|
||||||
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
|
all three rate limiters key on the connection's own address unless the direct
|
||||||
list: all three rate limiters key on the connection's own address
|
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to
|
||||||
unless the direct peer is a configured proxy, in which case
|
left for the first non-proxy hop. Default trusts nothing, and a
|
||||||
`X-Forwarded-For` is walked right to left for the first non-proxy hop.
|
set-but-unparseable value aborts startup. Before this, any client could mint a
|
||||||
Default trusts nothing, and a set-but-unparseable value aborts
|
fresh bucket or drain another's by rotating a spoofed header (#88)
|
||||||
startup. Before this, any client could mint a fresh bucket or drain
|
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile
|
||||||
another's by rotating a spoofed header (#88)
|
settings placeholder removed, a progressive-enhancement copy button for the
|
||||||
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
|
entrypoint URL, and retention form copy that states the actual policy
|
||||||
Profile settings placeholder removed, a progressive-enhancement copy
|
(deletion by the reaper, 0 retains forever) (#57)
|
||||||
button for the entrypoint URL, and retention form copy that states the
|
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds
|
||||||
actual policy (deletion by the reaper, 0 retains forever) (#57)
|
the request URL in `*url.Error`, so every transport failure persisted the full
|
||||||
- 2026-08-11 Mask the webhook credential in delivery errors and logs:
|
Slack webhook URL into the per-webhook event database via
|
||||||
Go embeds the request URL in `*url.Error`, so every transport failure
|
`DeliveryResult.Error`, a field a future REST API would have served.
|
||||||
persisted the full Slack webhook URL into the per-webhook event
|
`maskURLError` drops path, query and userinfo while preserving the wrapped
|
||||||
database via `DeliveryResult.Error`, a field a future REST API would
|
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout
|
||||||
have served. `maskURLError` drops path, query and userinfo while
|
failures still read differently (#118)
|
||||||
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
|
|
||||||
still work and DNS, TLS and timeout failures still read differently
|
|
||||||
(#118)
|
|
||||||
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
- 2026-08-11 Rate-limit the public webhook receiver endpoint
|
||||||
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
|
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint
|
||||||
entrypoint path so one entrypoint cannot exhaust another's budget;
|
path so one entrypoint cannot exhaust another's budget; over-limit requests
|
||||||
over-limit requests get 429 with `Retry-After`. It was the one
|
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing
|
||||||
unauthenticated, internet-facing endpoint with no limit at all (#64)
|
endpoint with no limit at all (#64)
|
||||||
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
- 2026-08-11 Enforce the body size limit before CSRF parses the form:
|
||||||
`MaxBodySize` is now first in all four form-parsing route groups, so
|
`MaxBodySize` is now first in all four form-parsing route groups, so an
|
||||||
an oversized request is rejected with 413 instead of being read in
|
oversized request is rejected with 413 instead of being read in full by the
|
||||||
full by the CSRF middleware before any cap applied (#90)
|
CSRF middleware before any cap applied (#90)
|
||||||
- 2026-08-11 Mask target config on the source detail page, which
|
- 2026-08-11 Mask target config on the source detail page, which rendered the
|
||||||
rendered the stored blob verbatim and so exposed the Slack
|
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer
|
||||||
incoming-webhook URL — a bearer credential that cannot be revoked
|
credential that cannot be revoked per-holder. Config reaches the template only
|
||||||
per-holder. Config reaches the template only as a `TargetView` of
|
as a `TargetView` of labelled fields, and header values are rendered as a
|
||||||
labelled fields, and header values are rendered as a count (#113)
|
count (#113)
|
||||||
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
|
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel
|
||||||
sentinel written in `BeforeSave` so the GORM column default cannot
|
written in `BeforeSave` so the GORM column default cannot win the race. Also
|
||||||
win the race. Also bounds the reaper's cutoff arithmetic: day counts
|
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed
|
||||||
above 106751 overflowed `time.Duration` and wrapped the cutoff into
|
`time.Duration` and wrapped the cutoff into the future, where every row
|
||||||
the future, where every row matched and the sweep deleted everything
|
matched and the sweep deleted everything (#79)
|
||||||
(#79)
|
|
||||||
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry
|
||||||
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
|
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests,
|
||||||
requests, with the 7-day absolute cap kept as an independent
|
with the 7-day absolute cap kept as an independent backstop that activity
|
||||||
backstop that activity never extends (#66)
|
never extends (#66)
|
||||||
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
|
||||||
orphaned `retrying` delivery whose target type no longer supports
|
orphaned `retrying` delivery whose target type no longer supports retries,
|
||||||
retries, recording a `DeliveryResult` with the reason instead of
|
recording a `DeliveryResult` with the reason instead of leaving the delivery
|
||||||
leaving the delivery stuck forever (#82)
|
stuck forever (#82)
|
||||||
- 2026-08-09 Root the delivery engine's worker pool and the retention
|
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's
|
||||||
reaper's sweep loop at `context.Background()` rather than the fx
|
sweep loop at `context.Background()` rather than the fx `OnStart` hook context
|
||||||
`OnStart` hook context (#97), which carries fx's 15s start timeout and
|
(#97), which carries fx's 15s start timeout and killed both roughly fifteen
|
||||||
killed both roughly fifteen seconds after boot: the proxy silently
|
seconds after boot: the proxy silently stopped delivering webhooks entirely,
|
||||||
stopped delivering webhooks entirely, and the reaper never ran a
|
and the reaper never ran a single sweep under its default one-hour interval
|
||||||
single sweep under its default one-hour interval
|
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last
|
||||||
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
|
`database` target) evicts the cached archive writer and closes its handle
|
||||||
last `database` target) evicts the cached archive writer and closes
|
while deliberately leaving `archive-{webhookID}.db` on disk, and a new
|
||||||
its handle while deliberately leaving `archive-{webhookID}.db` on
|
`ArchiveSweeper` prunes idle archives on the existing
|
||||||
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
|
|
||||||
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file
|
||||||
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||||
environment values: `envInt` removed in favour of `envPositiveInt`
|
environment values: `envInt` removed in favour of `envPositiveInt` plus a
|
||||||
plus a `PORT` range check, `envBool` now parses with
|
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and
|
||||||
`strconv.ParseBool`, and defaults apply only to unset variables (#80)
|
defaults apply only to unset variables (#80)
|
||||||
- 2026-08-07 Automatic event retention cleanup based on
|
- 2026-08-07 Automatic event retention cleanup based on `retention_days`,
|
||||||
`retention_days`, deleting expired events, deliveries, and delivery
|
deleting expired events, deliveries, and delivery results from each
|
||||||
results from each per-webhook event database (#63)
|
per-webhook event database (#63)
|
||||||
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||||
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
|
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the
|
||||||
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
|
canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||||
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
|
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly
|
||||||
all newly surfaced lint findings
|
surfaced lint findings
|
||||||
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
|
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile
|
||||||
Makefile shims, README Entrypoints section
|
shims, README Entrypoints section
|
||||||
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
|
||||||
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
|
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain
|
||||||
plain HTTP and behind reverse proxies (#54)
|
HTTP and behind reverse proxies (#54)
|
||||||
- 2026-03-17 root path redirects based on auth state (#52)
|
- 2026-03-17 root path redirects based on auth state (#52)
|
||||||
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
|
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS
|
||||||
with DNS rebinding defense, and per-IP login rate limiting (#42)
|
rebinding defense, and per-IP login rate limiting (#42)
|
||||||
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
|
||||||
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
|
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev
|
||||||
absolute dev DATA_DIR default and clarified env docs (#46)
|
DATA_DIR default and clarified env docs (#46)
|
||||||
- 2026-03-05 security headers middleware, session regeneration on
|
- 2026-03-05 security headers middleware, session regeneration on login, request
|
||||||
login, request body size limits (#41)
|
body size limits (#41)
|
||||||
- 2026-03-04 tests for delivery, middleware, and session packages
|
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed
|
||||||
(#32); removed the build-architecture global (#31)
|
the build-architecture global (#31)
|
||||||
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
|
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery
|
||||||
delivery engine with bounded worker pool and circuit breaker,
|
engine with bounded worker pool and circuit breaker, parallel fan-out,
|
||||||
parallel fan-out, per-webhook event databases, management UI (#16)
|
per-webhook event databases, management UI (#16)
|
||||||
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
|
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README
|
||||||
into README (#6)
|
(#6)
|
||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Delivery status and retry management UI. Replay of a terminally
|
- Delivery status and retry management UI. Replay of a terminally failed
|
||||||
failed delivery and per-attempt detail already landed
|
delivery and per-attempt detail already landed
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
(https://git.eeqj.de/sneak/webhooker/issues/203,
|
||||||
https://git.eeqj.de/sneak/webhooker/issues/202)
|
https://git.eeqj.de/sneak/webhooker/issues/202)
|
||||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
- Per-webhook rate limiting in the receiver handler (per-webhook config plus
|
||||||
plus handler enforcement; global limits must not apply to receiver
|
handler enforcement; global limits must not apply to receiver endpoints)
|
||||||
endpoints)
|
- API key authentication for programmatic access (APIKey model exists; Bearer
|
||||||
- API key authentication for programmatic access (APIKey model exists;
|
token middleware does not)
|
||||||
Bearer token middleware does not)
|
|
||||||
- REST API v1
|
- REST API v1
|
||||||
- CRUD for webhooks, entrypoints, targets
|
- CRUD for webhooks, entrypoints, targets
|
||||||
- event viewing and filtering endpoints
|
- event viewing and filtering endpoints
|
||||||
@@ -406,9 +370,9 @@ point of the branch.
|
|||||||
- OpenAPI specification
|
- OpenAPI specification
|
||||||
- Analytics dashboard: success rates, response times, volume
|
- Analytics dashboard: success rates, response times, volume
|
||||||
- A remember-me option at login
|
- A remember-me option at login
|
||||||
- Password reset flow for a forgotten password over the web. The
|
- Password reset flow for a forgotten password over the web. The authenticated
|
||||||
authenticated password *change* flow already landed, and a lost
|
password _change_ flow already landed, and a lost password is recoverable from
|
||||||
password is recoverable from the console with `webhooker resetpw`
|
the console with `webhooker resetpw`
|
||||||
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
(https://git.eeqj.de/sneak/webhooker/issues/208)
|
||||||
- Later, nice to have
|
- Later, nice to have
|
||||||
- email delivery target type
|
- email delivery target type
|
||||||
|
|||||||
+17
-7
@@ -4,6 +4,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -38,17 +39,19 @@ import (
|
|||||||
// hook that used the whole budget would exhaust it at that instant,
|
// hook that used the whole budget would exhaust it at that instant,
|
||||||
// and fx would skip every hook after the server — the delivery
|
// and fx would skip every hook after the server — the delivery
|
||||||
// engine, the healthcheck, the webhook DB manager and the database
|
// engine, the healthcheck, the webhook DB manager and the database
|
||||||
// close. That hook is the 3s HTTP drain plus the Sentry flush that
|
// close. That hook is the HTTP drain plus the Sentry flush that
|
||||||
// follows it in the same hook, so the flush is clamped to the stop
|
// follows it in the same hook, and each is clamped to the stop
|
||||||
// context's remaining time less server.TailHookReserve rather than
|
// context's remaining time less server.TailHookReserve rather than
|
||||||
// running for its own fixed 2s; the reserve is what the tail hooks
|
// running for its own fixed 3s and 2s; the reserve is what the tail
|
||||||
// live on, and they are microsecond-scale in normal operation.
|
// hooks live on, and they are microsecond-scale in normal operation.
|
||||||
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
|
||||||
// across every drain length.
|
// across every drain length and every amount of budget the hooks
|
||||||
|
// before the server may already have spent.
|
||||||
//
|
//
|
||||||
// This does not make the database close unconditional: the
|
// This does not make the database close unconditional: the
|
||||||
// ArchiveSweeper and RetentionReaper hooks run before the server
|
// ArchiveSweeper and RetentionReaper hooks run before the server.
|
||||||
// and can still consume the whole budget on their own.
|
// 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
|
const stopTimeout = 5 * time.Second
|
||||||
|
|
||||||
// exitUsage is the status for a command line this binary cannot make
|
// exitUsage is the status for a command line this binary cannot make
|
||||||
@@ -185,6 +188,9 @@ func newApp() *fx.App {
|
|||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
// The plain logger the session, the middleware and the
|
||||||
|
// webhook database manager take.
|
||||||
|
func(l *logger.Logger) *slog.Logger { return l.Get() },
|
||||||
config.New,
|
config.New,
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
@@ -210,6 +216,10 @@ func newApp() *fx.App {
|
|||||||
// or renaming a webhook or target reaches its archive
|
// or renaming a webhook or target reaches its archive
|
||||||
// files.
|
// files.
|
||||||
func(e *delivery.Engine) delivery.Archives { return e },
|
func(e *delivery.Engine) delivery.Archives { return e },
|
||||||
|
// Wire *delivery.Engine as delivery.CircuitBreakers so
|
||||||
|
// the pages can show a target whose deliveries are
|
||||||
|
// paused.
|
||||||
|
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
|
||||||
server.New,
|
server.New,
|
||||||
),
|
),
|
||||||
fx.Invoke(
|
fx.Invoke(
|
||||||
|
|||||||
+29
-11
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/resetpw"
|
"sneak.berlin/go/webhooker/internal/resetpw"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"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
|
// fx.New applies options before it executes invokes, so the timeout
|
||||||
// is set whether or not the graph itself can be constructed here.
|
// is set whether or not the graph itself can be constructed here.
|
||||||
func TestNewApp_StopTimeout(t *testing.T) {
|
func TestNewApp_StopTimeout(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
|
|
||||||
got := newApp().StopTimeout()
|
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
|
// anything is built, and the run of logger.New, which happens before
|
||||||
// the configuration sets the level.
|
// the configuration sets the level.
|
||||||
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
|
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
t.Setenv("DATA_DIR", t.TempDir())
|
||||||
t.Setenv("PORT", strconv.Itoa(freePort(t)))
|
t.Setenv("PORT", strconv.Itoa(freePort(t)))
|
||||||
t.Setenv("DEBUG", "true")
|
t.Setenv("DEBUG", "true")
|
||||||
@@ -252,22 +252,40 @@ const tailHeadroom = 2 * time.Second
|
|||||||
// can produce, since a shorter drain leaves the flush more room and
|
// can produce, since a shorter drain leaves the flush more room and
|
||||||
// the worst case is not necessarily at either extreme.
|
// the worst case is not necessarily at either extreme.
|
||||||
//
|
//
|
||||||
// Shrinking either budget, or unbounding the flush again, must fail
|
// Nor does the hook start on a full budget: the ArchiveSweeper and
|
||||||
// here rather than silently recreating a hook that swallows the
|
// RetentionReaper hooks run before it, and whatever they spent is
|
||||||
// whole sequence.
|
// gone. The outer sweep walks every amount they can spend. Once they
|
||||||
|
// have eaten into the headroom themselves, the hook must spend
|
||||||
|
// nothing of what is left. A drain that starts on the full budget
|
||||||
|
// must still get all of ShutdownTimeout, so a smaller stopTimeout
|
||||||
|
// cannot silently shorten every drain.
|
||||||
|
//
|
||||||
|
// Shrinking either budget, or unbounding the drain or the flush
|
||||||
|
// again, must fail here rather than silently recreating a hook that
|
||||||
|
// swallows the whole sequence.
|
||||||
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
require.Less(t, server.ShutdownTimeout, stopTimeout)
|
||||||
|
require.Equal(
|
||||||
|
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
|
||||||
|
"a drain that starts on the full stop budget is cut short",
|
||||||
|
)
|
||||||
|
|
||||||
const step = 10 * time.Millisecond
|
const step = 10 * time.Millisecond
|
||||||
|
|
||||||
for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
|
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
|
||||||
hook := drain + server.SentryFlushBudget(stopTimeout-drain)
|
remaining := stopTimeout - spent
|
||||||
|
longest := max(server.DrainBudget(remaining), 0)
|
||||||
|
|
||||||
require.LessOrEqual(
|
for drain := time.Duration(0); drain <= longest; drain += step {
|
||||||
t, hook+tailHeadroom, stopTimeout,
|
hook := drain + server.SentryFlushBudget(remaining-drain)
|
||||||
"a %s drain leaves the tail hooks short", drain,
|
|
||||||
|
require.GreaterOrEqual(
|
||||||
|
t, remaining-hook, min(remaining, tailHeadroom),
|
||||||
|
"a %s drain after %s of earlier hooks leaves "+
|
||||||
|
"the tail hooks short", drain, spent,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// ESLint configuration for static/js/. script/lint and the image build run
|
||||||
|
// ESLint in the Dockerfile's js-lint stage, never on the host.
|
||||||
|
//
|
||||||
|
// The rules are the ones the JavaScript styleguide linked from
|
||||||
|
// REPO_POLICIES.md states that a linter can check: const for everything,
|
||||||
|
// let only for a variable that is reassigned, never var.
|
||||||
|
export default [
|
||||||
|
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
|
||||||
|
{ ignores: ["static/js/alpine.min.js"] },
|
||||||
|
{
|
||||||
|
// The pages load static/js/app.js as a classic script, not a module.
|
||||||
|
languageOptions: { sourceType: "script" },
|
||||||
|
rules: {
|
||||||
|
"no-var": "error",
|
||||||
|
"prefer-const": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -22,7 +22,6 @@ require (
|
|||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
go.uber.org/fx v1.24.0
|
go.uber.org/fx v1.24.0
|
||||||
golang.org/x/crypto v0.38.0
|
golang.org/x/crypto v0.38.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
|
||||||
gorm.io/driver/sqlite v1.5.4
|
gorm.io/driver/sqlite v1.5.4
|
||||||
gorm.io/gorm v1.25.5
|
gorm.io/gorm v1.25.5
|
||||||
modernc.org/sqlite v1.28.0
|
modernc.org/sqlite v1.28.0
|
||||||
@@ -59,6 +58,7 @@ require (
|
|||||||
golang.org/x/text v0.25.0 // indirect
|
golang.org/x/text v0.25.0 // indirect
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
|
||||||
google.golang.org/protobuf v1.31.0 // indirect
|
google.golang.org/protobuf v1.31.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
lukechampine.com/uint128 v1.2.0 // indirect
|
lukechampine.com/uint128 v1.2.0 // indirect
|
||||||
modernc.org/cc/v3 v3.40.0 // indirect
|
modernc.org/cc/v3 v3.40.0 // indirect
|
||||||
modernc.org/ccgo/v3 v3.16.13 // indirect
|
modernc.org/ccgo/v3 v3.16.13 // indirect
|
||||||
|
|||||||
@@ -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/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"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
|
// 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
|
// sets one, is gone once the test ends, so it cannot reach the tests
|
||||||
// that run after it.
|
// that run after it.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||||
func TestClearEnvForTest_RemovesAddedVariables(t *testing.T) {
|
func TestClearEnv_RemovesAddedVariables(t *testing.T) {
|
||||||
// The outer clear keeps a value of the key exported in the shell
|
// 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.
|
// 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) {
|
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")
|
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
|
||||||
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
require.NoError(t, config.LoadDotEnvFileForTest(path))
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -70,7 +71,7 @@ func TestEnvironmentConfig(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if tt.envValue != "" {
|
if tt.envValue != "" {
|
||||||
t.Setenv(
|
t.Setenv(
|
||||||
@@ -196,7 +197,7 @@ func TestRetentionSweepInterval(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
@@ -335,7 +336,7 @@ func TestSessionIdleTimeout(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
@@ -388,7 +389,7 @@ func TestDefaultDataDir(t *testing.T) {
|
|||||||
t.Run("env="+name, func(t *testing.T) {
|
t.Run("env="+name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if env != "" {
|
if env != "" {
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
t.Setenv("WEBHOOKER_ENVIRONMENT", env)
|
||||||
@@ -433,7 +434,7 @@ func TestDataDirHelper(t *testing.T) {
|
|||||||
t.Run(name, func(t *testing.T) {
|
t.Run(name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if set != "" {
|
if set != "" {
|
||||||
t.Setenv("DATA_DIR", set)
|
t.Setenv("DATA_DIR", set)
|
||||||
@@ -498,7 +499,7 @@ func TestReceiverRateLimit(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
@@ -614,7 +615,7 @@ func TestTrustedProxies(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
@@ -725,7 +726,7 @@ func TestAllowedEgressCIDRs(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
@@ -797,7 +798,7 @@ func TestEgressAllowlistWarning(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
|
||||||
|
|
||||||
if tt.allowed != "" {
|
if tt.allowed != "" {
|
||||||
@@ -933,7 +934,7 @@ func TestMetricsAuthConfig(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if tt.username.set {
|
if tt.username.set {
|
||||||
t.Setenv("METRICS_USERNAME", tt.username.value)
|
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 (
|
import (
|
||||||
"os"
|
"os"
|
||||||
@@ -6,12 +8,12 @@ import (
|
|||||||
"testing"
|
"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
|
// 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
|
// itself, not whatever the developer's shell exports. When the test
|
||||||
// ends it leaves the environment exactly as it found it: each variable
|
// ends it leaves the environment exactly as it found it: each variable
|
||||||
// it unset is put back, and any variable added since is removed.
|
// it unset is put back, and any variable added since is removed.
|
||||||
func ClearEnvForTest(t *testing.T) {
|
func ClearEnv(t *testing.T) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
present := make(map[string]bool)
|
present := make(map[string]bool)
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// dotEnvKey is a throwaway variable name the .env tests write and
|
// 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
|
// normally rather than be refused for a file it was never meant to
|
||||||
// have.
|
// have.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||||
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
|
||||||
require.NoError(t, config.LoadDotEnvFileForTest(absent))
|
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
|
// reaches the environment, which is the whole reason the file is read
|
||||||
// at all.
|
// at all.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // ClearEnvForTest uses t.Setenv.
|
//nolint:paralleltest // ClearEnv uses t.Setenv.
|
||||||
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
func TestLoadDotEnv_AppliesValues(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
|
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
|
// reverts to its default; the process used to start that way with no
|
||||||
// log line naming the file at all.
|
// 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) {
|
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
path := writeDotEnv(
|
path := writeDotEnv(
|
||||||
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
|
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.
|
//nolint:paralleltest // t.Chdir moves the whole process.
|
||||||
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
require.NoError(t, os.WriteFile(
|
require.NoError(t, os.WriteFile(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -120,7 +121,7 @@ func TestEnvBool(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
@@ -169,7 +170,7 @@ func runEnvIntCases(
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
@@ -310,7 +311,7 @@ func TestEnvBindAddress(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(testEnvKey, tt.value)
|
t.Setenv(testEnvKey, tt.value)
|
||||||
@@ -476,7 +477,7 @@ func TestNewRejectsBadEnvValues(t *testing.T) {
|
|||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
t.Setenv(tt.key, tt.value)
|
t.Setenv(tt.key, tt.value)
|
||||||
|
|
||||||
@@ -638,7 +639,7 @@ func sentryEnvValueCases() []badEnvValueCase {
|
|||||||
// break the legitimate unset case: absent variables still get their
|
// break the legitimate unset case: absent variables still get their
|
||||||
// documented defaults.
|
// documented defaults.
|
||||||
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
cfg, err := buildConfig(t)
|
cfg, err := buildConfig(t)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// envKeySentryDSN is the variable envSentryDSN reads in production.
|
// 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) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
// Cannot use t.Parallel() here because t.Setenv
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
// is incompatible with parallel subtests.
|
// is incompatible with parallel subtests.
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
|
|
||||||
if tt.set {
|
if tt.set {
|
||||||
t.Setenv(envKeySentryDSN, tt.value)
|
t.Setenv(envKeySentryDSN, tt.value)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -119,3 +120,26 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
|
|||||||
t, second, created, "an existing database is not new",
|
t, second, created, "an existing database is not new",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestZeroLengthDatabase_IsLoggedAsNew covers what
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a
|
||||||
|
// zero-length file as an empty database, so a start on one is a first
|
||||||
|
// start, and it must say so exactly as a start with no file does.
|
||||||
|
func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, database.MainDBFileName)
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm))
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, db.Close())
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, out.String(),
|
||||||
|
`level=WARN msg="created a new, empty database" path=`+path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"io/fs"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -203,8 +202,7 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
// Checked before opening, which creates the file. A DATA_DIR that
|
// Checked before opening, which creates the file. A DATA_DIR that
|
||||||
// is unexpectedly empty -- its volume not mounted, say -- looks
|
// is unexpectedly empty -- its volume not mounted, say -- looks
|
||||||
// exactly like a first start, so a new database is a warning.
|
// exactly like a first start, so a new database is a warning.
|
||||||
_, statErr := os.Stat(dbPath)
|
created := missingOrEmpty(dbPath)
|
||||||
created := errors.Is(statErr, fs.ErrNotExist)
|
|
||||||
|
|
||||||
// Opened through OpenSQLite so this handle carries the same WAL
|
// Opened through OpenSQLite so this handle carries the same WAL
|
||||||
// journaling, busy timeout, immediate-transaction locking, and pool
|
// journaling, busy timeout, immediate-transaction locking, and pool
|
||||||
@@ -213,13 +211,15 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error(
|
d.log.Error(
|
||||||
"failed to open database",
|
"failed to open database",
|
||||||
|
"path", dbPath,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Then use it with GORM
|
// Then use it with GORM. Its errors are SQLite's alone and name no
|
||||||
|
// file, so the path is added to them here.
|
||||||
db, err := gorm.Open(sqlite.Dialector{
|
db, err := gorm.Open(sqlite.Dialector{
|
||||||
Conn: sqlDB,
|
Conn: sqlDB,
|
||||||
}, &gorm.Config{
|
}, &gorm.Config{
|
||||||
@@ -229,10 +229,11 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
d.log.Error(
|
d.log.Error(
|
||||||
"failed to connect to database",
|
"failed to connect to database",
|
||||||
|
"path", dbPath,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
return err
|
return fmt.Errorf("connecting to %s: %w", dbPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
d.db = db
|
d.db = db
|
||||||
@@ -243,8 +244,12 @@ func (d *Database) connectTo(dataDir string) error {
|
|||||||
d.log.Info("connected to database", "path", dbPath)
|
d.log.Info("connected to database", "path", dbPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run migrations
|
err = d.migrate()
|
||||||
return d.migrate()
|
if err != nil {
|
||||||
|
return fmt.Errorf("migrating %s: %w", dbPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Database) migrate() error {
|
func (d *Database) migrate() error {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
package database_test
|
package database_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
@@ -100,3 +106,22 @@ func TestDatabaseConnection(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpen_UnreadableDatabaseIsNamed pins
|
||||||
|
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
|
||||||
|
// read webhooker.db, the error that stops the server and `webhooker
|
||||||
|
// resetpw` names the file, not only SQLite's own message.
|
||||||
|
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, database.MainDBFileName)
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
|
||||||
|
))
|
||||||
|
|
||||||
|
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), path)
|
||||||
|
assert.Contains(t, err.Error(), "file is not a database")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
)
|
)
|
||||||
|
|
||||||
// testDataDirPerm is the mode the test data directory is created
|
// testDataDirPerm is the mode the test data directory is created
|
||||||
@@ -133,7 +134,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
|||||||
// Create the file the way the application does, so the targets
|
// Create the file the way the application does, so the targets
|
||||||
// table has exactly the shape AutoMigrate gives it, then write
|
// table has exactly the shape AutoMigrate gives it, then write
|
||||||
// a leaked row into it the way the association upsert did.
|
// a leaked row into it the way the association upsert did.
|
||||||
initial := database.NewTestWebhookDBManager(dataDir)
|
initial := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err := initial.GetDB(webhookID)
|
_, err := initial.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -156,7 +157,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
|||||||
clearEventDBSweptMarker(t, seed)
|
clearEventDBSweptMarker(t, seed)
|
||||||
require.NoError(t, seed.Close())
|
require.NoError(t, seed.Close())
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err = mgr.GetDB(webhookID)
|
_, err = mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -172,7 +173,7 @@ func TestOpenPurgesLeakedTargetRows(t *testing.T) {
|
|||||||
|
|
||||||
// Idempotent: a second open leaves it at zero and does not
|
// Idempotent: a second open leaves it at zero and does not
|
||||||
// error.
|
// error.
|
||||||
again := database.NewTestWebhookDBManager(dataDir)
|
again := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err = again.GetDB(webhookID)
|
_, err = again.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -195,7 +196,7 @@ func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
|
|||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
credential := "T00000000/B00000000/" + uuid.New().String()
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
||||||
|
|
||||||
initial := database.NewTestWebhookDBManager(dataDir)
|
initial := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err := initial.GetDB(webhookID)
|
_, err := initial.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -230,7 +231,7 @@ func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
|
|||||||
"seeded credential is not in the file, so this test proves nothing",
|
"seeded credential is not in the file, so this test proves nothing",
|
||||||
)
|
)
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err = mgr.GetDB(webhookID)
|
_, err = mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -258,7 +259,7 @@ func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
|
|||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
credential := "T00000000/B00000000/" + uuid.New().String()
|
credential := "T00000000/B00000000/" + uuid.New().String()
|
||||||
|
|
||||||
initial := database.NewTestWebhookDBManager(dataDir)
|
initial := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err := initial.GetDB(webhookID)
|
_, err := initial.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -298,7 +299,7 @@ func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
|
|||||||
"test proves nothing",
|
"test proves nothing",
|
||||||
)
|
)
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err = mgr.GetDB(webhookID)
|
_, err = mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -325,7 +326,7 @@ func TestOpenSkipsSweptDatabase(t *testing.T) {
|
|||||||
dataDir := eventDBDataDir(t)
|
dataDir := eventDBDataDir(t)
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err := mgr.GetDB(webhookID)
|
_, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -347,7 +348,7 @@ func TestOpenSkipsSweptDatabase(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, marked.Close())
|
require.NoError(t, marked.Close())
|
||||||
|
|
||||||
again := database.NewTestWebhookDBManager(dataDir)
|
again := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
_, err = again.GetDB(webhookID)
|
_, err = again.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -378,7 +379,7 @@ func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, seed.Close())
|
require.NoError(t, seed.Close())
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
db, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -396,7 +397,7 @@ func TestEventDBCreateOmitsAssociations(t *testing.T) {
|
|||||||
dataDir := eventDBDataDir(t)
|
dataDir := eventDBDataDir(t)
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManager(dataDir)
|
mgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
db, err := mgr.GetDB(webhookID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|||||||
@@ -149,6 +149,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
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
|
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
||||||
// page's statistics (readEventStats in the handlers): deliveries in
|
// page's statistics (readEventStats in the handlers): deliveries in
|
||||||
// progress, each target's deliveries finished since a time, which must
|
// progress, each target's deliveries finished since a time, which must
|
||||||
@@ -199,6 +255,77 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
"(deleted_at=? AND created_at>?)")
|
"(deleted_at=? AND created_at>?)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestResubmitCountUsesItsIndex does the same for the event log's count
|
||||||
|
// of the events resubmitted from each of a page's events (resubmitCounts
|
||||||
|
// in the handlers). It passes a full page of 25 ids: with an index on
|
||||||
|
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
|
||||||
|
// the deleted_at index from five.
|
||||||
|
func TestResubmitCountUsesItsIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(uuid.New().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dry := db.Session(&gorm.Session{DryRun: true})
|
||||||
|
|
||||||
|
page := make([]string, 25)
|
||||||
|
for i := range page {
|
||||||
|
page[i] = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts []struct{ Total int }
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||||
|
Select("resubmitted_from_id, count(*) AS total").
|
||||||
|
Where("resubmitted_from_id IN ?", page).
|
||||||
|
Group("resubmitted_from_id").Find(&counts),
|
||||||
|
"idx_events_resubmitted_from_id "+
|
||||||
|
"(resubmitted_from_id=? AND deleted_at=?)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntrypointEventsUseTheirIndex does the same for the webhook
|
||||||
|
// page's count, for each entrypoint, of the events that arrived on its
|
||||||
|
// URL since the retention cutoff (addEntrypointEvents in the
|
||||||
|
// handlers), which must come from the index alone. It passes 25
|
||||||
|
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
|
||||||
|
func TestEntrypointEventsUseTheirIndex(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
db, err := mgr.GetDB(uuid.New().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dry := db.Session(&gorm.Session{DryRun: true})
|
||||||
|
|
||||||
|
entrypoints := make([]string, 25)
|
||||||
|
for i := range entrypoints {
|
||||||
|
entrypoints[i] = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct{ Events int }
|
||||||
|
|
||||||
|
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
||||||
|
Select("entrypoint_id, count(*) AS events").
|
||||||
|
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
|
||||||
|
entrypoints).
|
||||||
|
Where("created_at >= ?", time.Now()).
|
||||||
|
Group("entrypoint_id").Find(&rows),
|
||||||
|
"COVERING INDEX idx_events_entrypoint_id "+
|
||||||
|
"(entrypoint_id=? AND deleted_at=? AND "+
|
||||||
|
"resubmitted_from_id=? AND created_at>?)")
|
||||||
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
// in a dry run, run with the same SQL and arguments GORM would send,
|
// in a dry run, run with the same SQL and arguments GORM would send,
|
||||||
// names each of the given indexes.
|
// names each of the given indexes.
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ type Delivery struct {
|
|||||||
// the index.
|
// the index.
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
||||||
|
|
||||||
|
// Replay is set on a delivery created by the event log's Replay
|
||||||
|
// action, so the pages can tell it from the delivery it repeats.
|
||||||
|
Replay bool `gorm:"not null;default:false" json:"replay"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// Event.Deliveries and Target.Deliveries lead back here, and the
|
// Event.Deliveries and Target.Deliveries lead back here, and the
|
||||||
// JSON could loop.
|
// JSON could loop.
|
||||||
|
|||||||
@@ -19,21 +19,28 @@ type Event struct {
|
|||||||
// narrows by a < only on the last column it uses. Its final delete
|
// narrows by a < only on the last column it uses. Its final delete
|
||||||
// has no deleted_at condition and uses the index on created_at
|
// has no deleted_at condition and uses the index on created_at
|
||||||
// alone. The other tables keep the unindexed BaseModel created_at.
|
// alone. The other tables keep the unindexed BaseModel created_at.
|
||||||
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
|
// DeletedAt is also the second column of the resubmitted_from_id
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
|
// index, for the reason DeliveryResult gives. The entrypoint_id
|
||||||
|
// index, for the webhook page's entrypoint list, has it second too,
|
||||||
|
// resubmitted_from_id third, and created_at last, which the list
|
||||||
|
// compares with a range.
|
||||||
|
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
|
||||||
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
|
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"`
|
Method string `gorm:"not null" json:"method"`
|
||||||
|
RawQuery string `gorm:"type:text" json:"rawQuery"`
|
||||||
Headers string `gorm:"type:text" json:"headers"` // JSON
|
Headers string `gorm:"type:text" json:"headers"` // JSON
|
||||||
Body string `gorm:"type:text" json:"body"`
|
Body string `gorm:"type:text" json:"body"`
|
||||||
ContentType string `json:"contentType"`
|
ContentType string `json:"contentType"`
|
||||||
|
|
||||||
// BodyBytes is the size of Body in bytes, recorded when the event
|
// BodyBytes is the size of Body in bytes, recorded when the event
|
||||||
// is stored so the recent events list can show it without reading
|
// is stored, so that the recent events list, which reads only the
|
||||||
// the body.
|
// start of each body, knows the whole body's size.
|
||||||
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied from by
|
// ResubmittedFromID names the event this one was copied from by
|
||||||
@@ -42,7 +49,7 @@ type Event struct {
|
|||||||
// existed. It is not a foreign key: the source event can be
|
// existed. It is not a foreign key: the source event can be
|
||||||
// reaped by retention while its copies remain, and the id is
|
// reaped by retention while its copies remain, and the id is
|
||||||
// kept as the record of where the copy came from either way.
|
// kept as the record of where the copy came from either way.
|
||||||
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
|
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to, so
|
// Relations. No model marshals the record it belongs to, so
|
||||||
// Webhook and Entrypoint are left out of the JSON.
|
// Webhook and Entrypoint are left out of the JSON.
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ type Target struct {
|
|||||||
// For HTTP targets (max_retries=0 means fire-and-forget,
|
// For HTTP targets (max_retries=0 means fire-and-forget,
|
||||||
// >0 enables retries with backoff)
|
// >0 enables retries with backoff)
|
||||||
MaxRetries int `json:"maxRetries,omitempty"`
|
MaxRetries int `json:"maxRetries,omitempty"`
|
||||||
MaxQueueSize int `json:"maxQueueSize,omitempty"`
|
|
||||||
|
|
||||||
// Relations. No model marshals the record it belongs to:
|
// Relations. No model marshals the record it belongs to:
|
||||||
// Webhook.Targets leads back here, and the JSON could loop.
|
// Webhook.Targets leads back here, and the JSON could loop.
|
||||||
|
|||||||
@@ -52,6 +52,21 @@ func (TargetTotals) TableName() string {
|
|||||||
return "target_totals"
|
return "target_totals"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntrypointTotals is one row per entrypoint, created by the first
|
||||||
|
// event that arrives on its URL: when the newest such event arrived,
|
||||||
|
// which retention leaves as it is. A resubmitted copy did not arrive
|
||||||
|
// on the URL and does not change it.
|
||||||
|
type EntrypointTotals struct {
|
||||||
|
EntrypointID string `gorm:"type:uuid;primaryKey"`
|
||||||
|
|
||||||
|
LastEventAt time.Time `gorm:"not null"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName names the table AddEntrypointTotals updates.
|
||||||
|
func (EntrypointTotals) TableName() string {
|
||||||
|
return "entrypoint_totals"
|
||||||
|
}
|
||||||
|
|
||||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
// AddEventTotals adds each count in add to the webhook's event totals,
|
||||||
// and records add.LastEventAt as when the newest event arrived if it is
|
// and records add.LastEventAt as when the newest event arrived if it is
|
||||||
// set. Call it on the transaction that writes or deletes the events it
|
// set. Call it on the transaction that writes or deletes the events it
|
||||||
@@ -97,3 +112,25 @@ func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AddEntrypointTotals records add.LastEventAt as when the newest event
|
||||||
|
// arrived on the URL of the entrypoint add.EntrypointID names, creating
|
||||||
|
// its row the first time. Call it on the transaction that stores the
|
||||||
|
// event.
|
||||||
|
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
|
||||||
|
err := tx.Exec(
|
||||||
|
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
|
||||||
|
VALUES (?, ?)
|
||||||
|
ON CONFLICT (entrypoint_id) DO UPDATE SET
|
||||||
|
last_event_at = excluded.last_event_at`,
|
||||||
|
add.EntrypointID, add.LastEventAt,
|
||||||
|
).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"adding to totals of entrypoint %s: %w",
|
||||||
|
add.EntrypointID, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ func (w *Webhook) RetainsForever() bool {
|
|||||||
return retainsForever(w.RetentionDays)
|
return retainsForever(w.RetentionDays)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RetentionCutoff returns the time before which this webhook's events
|
||||||
|
// have expired, as the reaper computes it, and false when the webhook
|
||||||
|
// retains them forever.
|
||||||
|
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
|
||||||
|
return retentionCutoff(now, w.RetentionDays)
|
||||||
|
}
|
||||||
|
|
||||||
// RetentionLabel returns the webhook's retention policy as display
|
// RetentionLabel returns the webhook's retention policy as display
|
||||||
// text, so that no template has to know about the sentinel value.
|
// text, so that no template has to know about the sentinel value.
|
||||||
func (w *Webhook) RetentionLabel() string {
|
func (w *Webhook) RetentionLabel() string {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package database
|
|||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
||||||
// TargetTotals) live in
|
// TargetTotals, EntrypointTotals) live in
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
|
|
||||||
wh := webhooks[i]
|
wh := webhooks[i]
|
||||||
|
|
||||||
// Nothing to reap if the per-webhook database has never
|
// A missing database has nothing to reap. Restart recovery
|
||||||
// been created.
|
// reports a lost one (see WebhookDBManager.GetDB).
|
||||||
if !r.dbManager.DBExists(wh.ID) {
|
if !r.dbManager.DBExists(wh.ID) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func setupRetentionTest(t *testing.T) *retentionTestEnv {
|
|||||||
|
|
||||||
mgr, err := database.NewWebhookDBManager(
|
mgr, err := database.NewWebhookDBManager(
|
||||||
lc,
|
lc,
|
||||||
database.WebhookDBManagerParams{Config: cfg, Logger: l},
|
database.WebhookDBManagerParams{Config: cfg, Logger: l.Get()},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,29 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
|
|||||||
requireDatabaseSetOwnerOnly(t, path)
|
requireDatabaseSetOwnerOnly(t, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place
|
||||||
|
// of -wal or -shm. Beside a -shm directory SQLite opens the database
|
||||||
|
// read-only without a word, and every write then fails naming no file,
|
||||||
|
// so the open must stop instead, naming the directory.
|
||||||
|
func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, suffix := range []string{"-wal", "-shm"} {
|
||||||
|
t.Run(suffix, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), database.MainDBFileName)
|
||||||
|
require.NoError(t, os.Mkdir(path+suffix, 0o700))
|
||||||
|
|
||||||
|
_, err := database.OpenSQLite(
|
||||||
|
path, database.SQLiteModeCreate,
|
||||||
|
)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), path+suffix)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
|
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
|
||||||
// the fix uses: OpenSQLite now creates the database file itself, and
|
// the fix uses: OpenSQLite now creates the database file itself, and
|
||||||
// must not do so for a caller that asked for an existing database. An
|
// must not do so for a caller that asked for an existing database. An
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
_ "modernc.org/sqlite" // Pure Go SQLite driver
|
||||||
@@ -93,7 +94,8 @@ const (
|
|||||||
const SQLiteFilePerm fs.FileMode = 0o600
|
const SQLiteFilePerm fs.FileMode = 0o600
|
||||||
|
|
||||||
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
|
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
|
||||||
// touches it, and tightens any sidecar already on disk.
|
// touches it, and tightens any sidecar already on disk. A directory in
|
||||||
|
// place of any of them is an error naming it.
|
||||||
//
|
//
|
||||||
// The mode has to be settled here rather than by a chmod after opening,
|
// The mode has to be settled here rather than by a chmod after opening,
|
||||||
// because SQLite picks it: robust_open substitutes
|
// because SQLite picks it: robust_open substitutes
|
||||||
@@ -143,7 +145,15 @@ func reserveSQLiteFile(path string, create bool) error {
|
|||||||
for _, p := range append(
|
for _, p := range append(
|
||||||
[]string{path}, sqliteSidecarPaths(path)...,
|
[]string{path}, sqliteSidecarPaths(path)...,
|
||||||
) {
|
) {
|
||||||
err := os.Chmod(p, SQLiteFilePerm)
|
// Chmod accepts a directory, and SQLite opens a database whose
|
||||||
|
// -shm is one read-only, without a word: every write then
|
||||||
|
// fails naming no file.
|
||||||
|
info, err := os.Stat(p)
|
||||||
|
if err == nil && info.IsDir() {
|
||||||
|
return fmt.Errorf("securing %s: %w", p, syscall.EISDIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Chmod(p, SQLiteFilePerm)
|
||||||
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
return fmt.Errorf("securing %s: %w", p, err)
|
return fmt.Errorf("securing %s: %w", p, err)
|
||||||
}
|
}
|
||||||
@@ -152,6 +162,20 @@ func reserveSQLiteFile(path string, create bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// missingOrEmpty reports whether opening path in SQLiteModeCreate
|
||||||
|
// would start a new, empty database: the file is not there, or it is
|
||||||
|
// zero-length, which SQLite opens as an empty database. A file left at
|
||||||
|
// zero length by an interrupted first start or a truncated copy holds
|
||||||
|
// as little as a missing one, and must be reported the same way.
|
||||||
|
func missingOrEmpty(path string) bool {
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return err == nil && info.Size() == 0
|
||||||
|
}
|
||||||
|
|
||||||
// sqliteSidecarPaths returns the files SQLite maintains beside a
|
// sqliteSidecarPaths returns the files SQLite maintains beside a
|
||||||
// database under WAL. They carry the same rows as the database itself,
|
// database under WAL. They carry the same rows as the database itself,
|
||||||
// so a fix that tightens only the main file has fixed nothing.
|
// so a fix that tightens only the main file has fixed nothing.
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -15,7 +15,6 @@ import (
|
|||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/datadir"
|
"sneak.berlin/go/webhooker/internal/datadir"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WebhookDBManagerParams holds the fx dependencies for
|
// WebhookDBManagerParams holds the fx dependencies for
|
||||||
@@ -24,7 +23,7 @@ type WebhookDBManagerParams struct {
|
|||||||
fx.In
|
fx.In
|
||||||
|
|
||||||
Config *config.Config
|
Config *config.Config
|
||||||
Logger *logger.Logger
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
// errInvalidCachedDBType indicates a type assertion failure
|
// errInvalidCachedDBType indicates a type assertion failure
|
||||||
@@ -33,10 +32,23 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
"invalid cached database type",
|
"invalid cached database type",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ErrEventDBNotRemoved is in DeleteDB's error when the event
|
||||||
|
// database file itself could not be removed: it is still on disk.
|
||||||
|
var ErrEventDBNotRemoved = errors.New(
|
||||||
|
"event database file not removed",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrSidecarNotRemoved is in DeleteDB's error when the event
|
||||||
|
// database file was removed, so its events are gone, but its -wal
|
||||||
|
// or -shm sidecar could not be.
|
||||||
|
var ErrSidecarNotRemoved = errors.New(
|
||||||
|
"event database file removed, but a -wal or -shm sidecar was not",
|
||||||
|
)
|
||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, DeliveryResults and the
|
// database containing Events, Deliveries, DeliveryResults and the
|
||||||
// running totals of them (EventTotals, TargetTotals).
|
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -57,7 +69,7 @@ func NewWebhookDBManager(
|
|||||||
) (*WebhookDBManager, error) {
|
) (*WebhookDBManager, error) {
|
||||||
m := &WebhookDBManager{
|
m := &WebhookDBManager{
|
||||||
dataDir: params.Config.DataDir,
|
dataDir: params.Config.DataDir,
|
||||||
log: params.Logger.Get(),
|
log: params.Logger,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create data directory if it doesn't exist. datadir.DirPerm is the
|
// Create data directory if it doesn't exist. datadir.DirPerm is the
|
||||||
@@ -85,34 +97,37 @@ func NewWebhookDBManager(
|
|||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the database connection for a webhook,
|
// GetDB returns the database connection for a webhook, opening it on
|
||||||
// creating the database file lazily if it doesn't exist.
|
// first use.
|
||||||
|
//
|
||||||
|
// The file is made by CreateDB when the webhook is created. One that is
|
||||||
|
// missing or zero-length here means the webhook's events and pending
|
||||||
|
// deliveries are gone: an empty database is created in its place so
|
||||||
|
// the webhook keeps receiving, and that is logged as a warning naming
|
||||||
|
// the file, as a new main database is.
|
||||||
func (m *WebhookDBManager) GetDB(
|
func (m *WebhookDBManager) GetDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) (*gorm.DB, error) {
|
) (*gorm.DB, error) {
|
||||||
// Fast path: already open
|
return m.getDB(webhookID, false)
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
}
|
||||||
return asGormDB(val, webhookID)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slow path: open the database under the lock, looking in the
|
// GetDBIf is GetDB, done only when check reports true. check runs under
|
||||||
// cache again first. A caller that raced another one here then
|
// the lock DeleteDB holds while it removes the files, so a caller can
|
||||||
// waits for its handle instead of opening a second one.
|
// confirm the webhook still exists and open its database with no delete
|
||||||
|
// in between. The handle is nil when check reports false. check must
|
||||||
|
// not call the manager.
|
||||||
|
func (m *WebhookDBManager) GetDBIf(
|
||||||
|
webhookID string, check func() (bool, error),
|
||||||
|
) (*gorm.DB, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
if val, ok := m.dbs.Load(webhookID); ok {
|
ok, err := check()
|
||||||
return asGormDB(val, webhookID)
|
if err != nil || !ok {
|
||||||
}
|
|
||||||
|
|
||||||
db, err := m.openDB(webhookID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
m.dbs.Store(webhookID, db)
|
return m.getDBLocked(webhookID, false)
|
||||||
|
|
||||||
return db, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// asGormDB returns a value read from the cache as the database
|
// asGormDB returns a value read from the cache as the database
|
||||||
@@ -130,12 +145,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) {
|
|||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateDB explicitly creates a new per-webhook database file
|
// CreateDB creates a new webhook's database file and runs
|
||||||
// and runs migrations.
|
// migrations.
|
||||||
func (m *WebhookDBManager) CreateDB(
|
func (m *WebhookDBManager) CreateDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
_, err := m.GetDB(webhookID)
|
_, err := m.getDB(webhookID, true)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -151,7 +166,10 @@ func (m *WebhookDBManager) DBExists(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DeleteDB closes the connection and deletes the database file
|
// DeleteDB closes the connection and deletes the database file
|
||||||
// for a webhook. The file is permanently removed.
|
// for a webhook, with its -wal and -shm sidecars. The files are
|
||||||
|
// permanently removed. Each file is tried even when another could
|
||||||
|
// not be removed, and the error wraps ErrEventDBNotRemoved or
|
||||||
|
// ErrSidecarNotRemoved to say which was left, naming each file.
|
||||||
func (m *WebhookDBManager) DeleteDB(
|
func (m *WebhookDBManager) DeleteDB(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) error {
|
) error {
|
||||||
@@ -170,16 +188,23 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete the main DB file and WAL/SHM files
|
|
||||||
path := m.dbPath(webhookID)
|
path := m.dbPath(webhookID)
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
||||||
err := os.Remove(path + suffix)
|
dbErr := removeFile(path)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
sidecarErr := errors.Join(
|
||||||
|
removeFile(path+"-wal"),
|
||||||
|
removeFile(path+"-shm"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if dbErr != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"deleting webhook database file %s%s: %w",
|
"%w: %w",
|
||||||
path, suffix, err,
|
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sidecarErr != nil {
|
||||||
|
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
@@ -190,6 +215,17 @@ func (m *WebhookDBManager) DeleteDB(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeFile removes path. A file that is already gone counts as
|
||||||
|
// removed; the error from any other failure names the file.
|
||||||
|
func removeFile(path string) error {
|
||||||
|
err := os.Remove(path)
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// CloseAll closes all open per-webhook database connections.
|
// CloseAll closes all open per-webhook database connections.
|
||||||
// Called during application shutdown.
|
// Called during application shutdown.
|
||||||
func (m *WebhookDBManager) CloseAll() error {
|
func (m *WebhookDBManager) CloseAll() error {
|
||||||
@@ -232,6 +268,54 @@ func (m *WebhookDBManager) DBPath(
|
|||||||
return m.dbPath(webhookID)
|
return m.dbPath(webhookID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getDB is GetDB, and CreateDB when isNew is true: the webhook has just
|
||||||
|
// been created, so a missing file is expected rather than lost.
|
||||||
|
func (m *WebhookDBManager) getDB(
|
||||||
|
webhookID string, isNew bool,
|
||||||
|
) (*gorm.DB, error) {
|
||||||
|
// Fast path: already open
|
||||||
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
|
return asGormDB(val, webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
return m.getDBLocked(webhookID, isNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDBLocked is getDB's slow path, run with m.mu held. It looks in the
|
||||||
|
// cache again first: a caller that raced another one to the lock then
|
||||||
|
// gets its handle instead of opening a second one.
|
||||||
|
func (m *WebhookDBManager) getDBLocked(
|
||||||
|
webhookID string, isNew bool,
|
||||||
|
) (*gorm.DB, error) {
|
||||||
|
if val, ok := m.dbs.Load(webhookID); ok {
|
||||||
|
return asGormDB(val, webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Checked before opening, which creates the file. See GetDB.
|
||||||
|
path := m.dbPath(webhookID)
|
||||||
|
replaced := !isNew && missingOrEmpty(path)
|
||||||
|
|
||||||
|
db, err := m.openDB(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if replaced {
|
||||||
|
m.log.Warn(
|
||||||
|
"created a new, empty database",
|
||||||
|
"webhook_id", webhookID,
|
||||||
|
"path", path,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.dbs.Store(webhookID, db)
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *WebhookDBManager) dbPath(
|
func (m *WebhookDBManager) dbPath(
|
||||||
webhookID string,
|
webhookID string,
|
||||||
) string {
|
) string {
|
||||||
@@ -296,7 +380,7 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
&EventTotals{}, &TargetTotals{},
|
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
)
|
)
|
||||||
@@ -50,7 +51,7 @@ func setupTestWebhookDBManager(
|
|||||||
lc,
|
lc,
|
||||||
database.WebhookDBManagerParams{
|
database.WebhookDBManagerParams{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Logger: l,
|
Logger: l.Get(),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -117,7 +118,8 @@ func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) {
|
|||||||
|
|
||||||
var logs bytes.Buffer
|
var logs bytes.Buffer
|
||||||
|
|
||||||
mgr := database.NewTestWebhookDBManagerWithLogger(
|
mgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||||
|
t,
|
||||||
t.TempDir(),
|
t.TempDir(),
|
||||||
slog.New(slog.NewTextHandler(&logs, nil)),
|
slog.New(slog.NewTextHandler(&logs, nil)),
|
||||||
)
|
)
|
||||||
@@ -182,17 +184,91 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.NoError(t, db.Create(event).Error)
|
require.NoError(t, db.Create(event).Error)
|
||||||
|
|
||||||
|
// Under WAL, an open database that has been written to has both
|
||||||
|
// sidecars beside it.
|
||||||
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
require.FileExists(t, dbPath+"-wal")
|
||||||
|
require.FileExists(t, dbPath+"-shm")
|
||||||
|
|
||||||
// Delete the DB
|
// Delete the DB
|
||||||
require.NoError(t, mgr.DeleteDB(webhookID))
|
require.NoError(t, mgr.DeleteDB(webhookID))
|
||||||
|
|
||||||
// File should no longer exist
|
// File should no longer exist
|
||||||
assert.False(t, mgr.DBExists(webhookID))
|
assert.False(t, mgr.DBExists(webhookID))
|
||||||
|
|
||||||
// Verify the file is actually gone from disk
|
// Verify the files are actually gone from disk
|
||||||
|
assert.NoFileExists(t, dbPath)
|
||||||
|
assert.NoFileExists(t, dbPath+"-wal")
|
||||||
|
assert.NoFileExists(t, dbPath+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// blockRemoval puts a non-empty directory at path, which os.Remove
|
||||||
|
// cannot remove whoever runs the test, root included.
|
||||||
|
func blockRemoval(t *testing.T, path string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
|
||||||
|
// event database file cannot be removed, the error says so, and both
|
||||||
|
// sidecars are still removed.
|
||||||
|
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
dbPath := mgr.DBPath(webhookID)
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
_, err = os.Stat(dbPath)
|
blockRemoval(t, dbPath)
|
||||||
assert.True(t, os.IsNotExist(err))
|
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
|
||||||
|
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
||||||
|
|
||||||
|
err := mgr.DeleteDB(webhookID)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
|
||||||
|
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
|
||||||
|
assert.Contains(t, err.Error(), dbPath)
|
||||||
|
assert.NoFileExists(t, dbPath+"-wal")
|
||||||
|
assert.NoFileExists(t, dbPath+"-shm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
|
||||||
|
// event database file is removed but a sidecar is not, the error
|
||||||
|
// says the database file is gone, and the other sidecar is still
|
||||||
|
// removed.
|
||||||
|
func TestWebhookDBManager_DeleteDBKeepsSidecar(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mgr, lc := setupTestWebhookDBManager(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
require.NoError(t, lc.Start(ctx))
|
||||||
|
|
||||||
|
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
dbPath := mgr.DBPath(webhookID)
|
||||||
|
|
||||||
|
require.NoError(t, mgr.CreateDB(webhookID))
|
||||||
|
// Closing removes the sidecars, so the ones below are the only
|
||||||
|
// ones there.
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
blockRemoval(t, dbPath+"-wal")
|
||||||
|
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
|
||||||
|
|
||||||
|
err := mgr.DeleteDB(webhookID)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
|
||||||
|
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
|
||||||
|
assert.Contains(t, err.Error(), dbPath+"-wal")
|
||||||
|
assert.NoFileExists(t, dbPath)
|
||||||
|
assert.NoFileExists(t, dbPath+"-shm")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
||||||
@@ -215,6 +291,76 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
|
|||||||
assert.True(t, mgr.DBExists(webhookID))
|
assert.True(t, mgr.DBExists(webhookID))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A webhook's database is made by CreateDB along with the webhook. One
|
||||||
|
// that GetDB finds missing or zero-length has lost the webhook's events
|
||||||
|
// and pending deliveries, so the empty database made in its place is
|
||||||
|
// logged as a warning naming the file
|
||||||
|
// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and
|
||||||
|
// reopening a database that is there, log no such warning.
|
||||||
|
func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const created = `level=WARN msg="created a new, empty database"`
|
||||||
|
|
||||||
|
open := func(
|
||||||
|
t *testing.T, prepare func(*database.WebhookDBManager, string),
|
||||||
|
) (string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
mgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||||
|
t,
|
||||||
|
t.TempDir(),
|
||||||
|
slog.New(slog.NewTextHandler(&logs, nil)),
|
||||||
|
)
|
||||||
|
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
prepare(mgr, webhookID)
|
||||||
|
|
||||||
|
_, err := mgr.GetDB(webhookID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
|
||||||
|
return logs.String(),
|
||||||
|
" webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("missing", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logs, fields := open(
|
||||||
|
t, func(*database.WebhookDBManager, string) {},
|
||||||
|
)
|
||||||
|
assert.Contains(t, logs, created+fields)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("zero-length", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logs, fields := open(
|
||||||
|
t, func(mgr *database.WebhookDBManager, webhookID string) {
|
||||||
|
require.NoError(t, os.WriteFile(
|
||||||
|
mgr.DBPath(webhookID), nil, database.SQLiteFilePerm,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert.Contains(t, logs, created+fields)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("created with the webhook, then reopened", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logs, _ := open(
|
||||||
|
t, func(mgr *database.WebhookDBManager, webhookID string) {
|
||||||
|
require.NoError(t, mgr.CreateDB(webhookID))
|
||||||
|
require.NoError(t, mgr.CloseAll())
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert.NotContains(t, logs, created)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ import (
|
|||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -58,28 +60,14 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
|
|||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
log := archiveTestLogger()
|
log := archiveTestLogger()
|
||||||
|
|
||||||
sqlDB, err := sql.Open(
|
mainDB, err := database.Open(dataDir, slog.New(slog.DiscardHandler))
|
||||||
"sqlite",
|
|
||||||
fmt.Sprintf(
|
|
||||||
"file:%s?mode=rwc",
|
|
||||||
filepath.Join(dataDir, "main.db"),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = mainDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
mainDB := database.NewTestDatabase(gdb)
|
|
||||||
require.NoError(t, mainDB.Migrate())
|
|
||||||
|
|
||||||
eng := delivery.NewTestEngineWithDB(
|
eng := delivery.NewTestEngineWithDB(
|
||||||
mainDB,
|
mainDB,
|
||||||
database.NewTestWebhookDBManager(dataDir),
|
databasetest.NewWebhookDBManager(t, dataDir),
|
||||||
log,
|
log,
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
1,
|
1,
|
||||||
@@ -161,6 +149,17 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
path := env.archivePath(tgt)
|
path := env.archivePath(tgt)
|
||||||
|
seedArchiveFile(t, path, tgt.WebhookID, archivedAt...)
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedArchiveFile creates the archive file at path and inserts one row
|
||||||
|
// per supplied archived-at timestamp, as seedArchiveRows does.
|
||||||
|
func seedArchiveFile(
|
||||||
|
t *testing.T, path, webhookID string, archivedAt ...time.Time,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
sqlDB, err := sql.Open(
|
sqlDB, err := sql.Open(
|
||||||
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
|
||||||
@@ -168,7 +167,8 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
for i, at := range archivedAt {
|
for i, at := range archivedAt {
|
||||||
row := delivery.ExportArchivedEvent{
|
row := delivery.ExportArchivedEvent{
|
||||||
EventID: fmt.Sprintf("ev-%d", i),
|
EventID: fmt.Sprintf("ev-%d", i),
|
||||||
WebhookID: tgt.WebhookID,
|
WebhookID: webhookID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
Body: `{"seeded":true}`,
|
Body: `{"seeded":true}`,
|
||||||
ArchivedAt: at,
|
ArchivedAt: at,
|
||||||
@@ -188,8 +188,6 @@ func (env *archiveEnv) seedArchiveRows(
|
|||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, sqlDB.Close())
|
require.NoError(t, sqlDB.Close())
|
||||||
|
|
||||||
return path
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivedEventIDs returns the event ids currently stored in an
|
// archivedEventIDs returns the event ids currently stored in an
|
||||||
@@ -227,7 +225,8 @@ func countArchivedRows(path string) (int64, error) {
|
|||||||
defer func() { _ = sqlDB.Close() }()
|
defer func() { _ = sqlDB.Close() }()
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0, err
|
return 0, err
|
||||||
|
|||||||
@@ -102,6 +102,20 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
|
|||||||
return remaining
|
return remaining
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateAndCooldown returns the circuit state and, while the circuit is
|
||||||
|
// open, what is left of the cooldown, or zero once that has passed.
|
||||||
|
// Both are read under one lock, so they always agree.
|
||||||
|
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
|
||||||
|
cb.mu.Lock()
|
||||||
|
defer cb.mu.Unlock()
|
||||||
|
|
||||||
|
if cb.state != CircuitOpen {
|
||||||
|
return cb.state, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
|
||||||
|
}
|
||||||
|
|
||||||
// RecordSuccess records a successful delivery and resets
|
// RecordSuccess records a successful delivery and resets
|
||||||
// the circuit breaker to closed state.
|
// the circuit breaker to closed state.
|
||||||
func (cb *CircuitBreaker) RecordSuccess() {
|
func (cb *CircuitBreaker) RecordSuccess() {
|
||||||
|
|||||||
+70
-13
@@ -110,6 +110,7 @@ type Task struct {
|
|||||||
MaxRetries int
|
MaxRetries int
|
||||||
|
|
||||||
Method string
|
Method string
|
||||||
|
RawQuery string
|
||||||
Headers string
|
Headers string
|
||||||
ContentType string
|
ContentType string
|
||||||
Body *string
|
Body *string
|
||||||
@@ -143,6 +144,15 @@ type Archives interface {
|
|||||||
Rename(targetID, webhookName, targetName string) error
|
Rename(targetID, webhookName, targetName string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CircuitBreakers is how the handlers read a target's circuit
|
||||||
|
// breaker, so the webhook page and the event log can say that
|
||||||
|
// deliveries to the target are paused and until when. Like Archives,
|
||||||
|
// it keeps the handlers free of the engine's internals and is
|
||||||
|
// trivially faked in tests.
|
||||||
|
type CircuitBreakers interface {
|
||||||
|
StateAndCooldown(targetID string) (CircuitState, time.Duration)
|
||||||
|
}
|
||||||
|
|
||||||
// EngineParams are the fx dependencies for the delivery
|
// EngineParams are the fx dependencies for the delivery
|
||||||
// engine.
|
// engine.
|
||||||
type EngineParams struct {
|
type EngineParams struct {
|
||||||
@@ -186,9 +196,11 @@ type Engine struct {
|
|||||||
// targets maps each target type to its implementation.
|
// targets maps each target type to its implementation.
|
||||||
targets map[database.TargetType]Target
|
targets map[database.TargetType]Target
|
||||||
|
|
||||||
// httpTarget is retained so tests can reach the HTTP
|
// httpTarget and slackTarget are retained so StateAndCooldown
|
||||||
// target's shared client and circuit breakers.
|
// can read their circuit breakers, and so tests can reach the
|
||||||
|
// HTTP target's shared client.
|
||||||
httpTarget *httpTarget
|
httpTarget *httpTarget
|
||||||
|
slackTarget *slackTarget
|
||||||
|
|
||||||
// dbTarget is retained so the engine can reach the archive
|
// dbTarget is retained so the engine can reach the archive
|
||||||
// writer registry for eviction, renames and the idle sweep.
|
// writer registry for eviction, renames and the idle sweep.
|
||||||
@@ -284,12 +296,13 @@ func (e *Engine) EvictTarget(targetID string) {
|
|||||||
e.dbTarget.evict(targetID)
|
e.dbTarget.evict(targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename implements Archives. It renames a database target's
|
// Rename implements Archives. It renames every one of a database
|
||||||
// archive file to ArchiveFileName(webhookName, targetName,
|
// target's archive files to ArchiveFileName(webhookName, targetName,
|
||||||
// targetID), under the lock the target's archive writes and the
|
// targetID), each keeping the period in its name, under the lock the
|
||||||
// idle sweep take. It never replaces a file: if one already has the
|
// target's archive writes and the idle sweep take. It never replaces
|
||||||
// new name, the error is ErrArchiveNameTaken. The caller renames
|
// a file: if one already has a new name, the error is
|
||||||
// before it saves the new name: see databaseTarget.rename.
|
// ErrArchiveNameTaken. The caller renames before it saves the new
|
||||||
|
// name: see databaseTarget.rename.
|
||||||
func (e *Engine) Rename(
|
func (e *Engine) Rename(
|
||||||
targetID, webhookName, targetName string,
|
targetID, webhookName, targetName string,
|
||||||
) error {
|
) error {
|
||||||
@@ -300,6 +313,28 @@ func (e *Engine) Rename(
|
|||||||
return e.dbTarget.rename(targetID, webhookName, targetName)
|
return e.dbTarget.rename(targetID, webhookName, targetName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StateAndCooldown implements CircuitBreakers. It returns the state of
|
||||||
|
// the target's circuit breaker and, while the breaker is open, what is
|
||||||
|
// left of its cooldown; the cooldown is zero once that has passed and
|
||||||
|
// in any other state. A target with no breaker reads as closed with no
|
||||||
|
// cooldown, and reading never creates one.
|
||||||
|
func (e *Engine) StateAndCooldown(
|
||||||
|
targetID string,
|
||||||
|
) (CircuitState, time.Duration) {
|
||||||
|
for _, core := range []*httpCore{
|
||||||
|
e.httpTarget.httpCore, e.slackTarget.httpCore,
|
||||||
|
} {
|
||||||
|
val, ok := core.circuitBreakers.Load(targetID)
|
||||||
|
if ok {
|
||||||
|
cb, _ := val.(*CircuitBreaker)
|
||||||
|
|
||||||
|
return cb.StateAndCooldown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CircuitClosed, 0
|
||||||
|
}
|
||||||
|
|
||||||
// ScheduleRetry schedules a task to be re-enqueued onto the
|
// ScheduleRetry schedules a task to be re-enqueued onto the
|
||||||
// retry channel after delay. It implements the Scheduler
|
// retry channel after delay. It implements the Scheduler
|
||||||
// interface the targets use to own their durable retries.
|
// interface the targets use to own their durable retries.
|
||||||
@@ -699,10 +734,9 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
|||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
if !e.dbManager.DBExists(webhookID) {
|
// Opened even when its file is missing, so that a lost
|
||||||
continue
|
// database is reported at start, not when the webhook next
|
||||||
}
|
// receives an event, which for a quiet webhook may be never.
|
||||||
|
|
||||||
e.recoverWebhookDeliveries(ctx, webhookID)
|
e.recoverWebhookDeliveries(ctx, webhookID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -710,7 +744,24 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
|
|||||||
func (e *Engine) recoverWebhookDeliveries(
|
func (e *Engine) recoverWebhookDeliveries(
|
||||||
ctx context.Context, webhookID string,
|
ctx context.Context, webhookID string,
|
||||||
) {
|
) {
|
||||||
webhookDB, err := e.dbManager.GetDB(webhookID)
|
// The web interface is already serving, so the webhook may have
|
||||||
|
// been deleted since the list was read. Opening its database then
|
||||||
|
// would create the file again after the delete removed it.
|
||||||
|
stillExists := func() (bool, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
err := e.database.DB().
|
||||||
|
Model(&database.Webhook{}).
|
||||||
|
Where("id = ?", webhookID).
|
||||||
|
Count(&count).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("confirming webhook exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := e.dbManager.GetDBIf(webhookID, stillExists)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
e.log.Error(
|
e.log.Error(
|
||||||
"failed to get webhook database for recovery",
|
"failed to get webhook database for recovery",
|
||||||
@@ -721,6 +772,10 @@ func (e *Engine) recoverWebhookDeliveries(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if webhookDB == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
e.recoverPendingDeliveries(
|
e.recoverPendingDeliveries(
|
||||||
ctx, webhookDB, webhookID,
|
ctx, webhookDB, webhookID,
|
||||||
)
|
)
|
||||||
@@ -1698,6 +1753,7 @@ func buildEventFromTask(task *Task) database.Event {
|
|||||||
event := database.Event{
|
event := database.Event{
|
||||||
EntrypointID: task.EntrypointID,
|
EntrypointID: task.EntrypointID,
|
||||||
Method: task.Method,
|
Method: task.Method,
|
||||||
|
RawQuery: task.RawQuery,
|
||||||
Headers: task.Headers,
|
Headers: task.Headers,
|
||||||
ContentType: task.ContentType,
|
ContentType: task.ContentType,
|
||||||
}
|
}
|
||||||
@@ -2048,6 +2104,7 @@ func buildRecoveryTask(
|
|||||||
TargetConfig: target.Config,
|
TargetConfig: target.Config,
|
||||||
MaxRetries: target.MaxRetries,
|
MaxRetries: target.MaxRetries,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
|
RawQuery: event.RawQuery,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
ContentType: event.ContentType,
|
ContentType: event.ContentType,
|
||||||
Body: bodyPtr,
|
Body: bodyPtr,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package delivery_test
|
package delivery_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,7 +10,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -18,10 +18,10 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,12 +43,12 @@ func newISetup(t *testing.T) iSetup {
|
|||||||
wDB := iSeedWebhookDB(t, dbMgr, wID)
|
wDB := iSeedWebhookDB(t, dbMgr, wID)
|
||||||
|
|
||||||
return iSetup{
|
return iSetup{
|
||||||
MainDB: mainDB,
|
MainDB: mainDB.DB(),
|
||||||
DBMgr: dbMgr,
|
DBMgr: dbMgr,
|
||||||
WebhookID: wID,
|
WebhookID: wID,
|
||||||
WebhookDB: wDB,
|
WebhookDB: wDB,
|
||||||
Engine: delivery.NewTestEngineWithDB(
|
Engine: delivery.NewTestEngineWithDB(
|
||||||
database.NewTestDatabase(mainDB),
|
mainDB,
|
||||||
dbMgr,
|
dbMgr,
|
||||||
slog.New(slog.NewTextHandler(
|
slog.New(slog.NewTextHandler(
|
||||||
os.Stderr,
|
os.Stderr,
|
||||||
@@ -62,34 +62,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()
|
t.Helper()
|
||||||
|
|
||||||
dbPath := filepath.Join(
|
db, err := database.Open(t.TempDir(), slog.New(slog.DiscardHandler))
|
||||||
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,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, db.AutoMigrate(
|
|
||||||
&database.Webhook{},
|
|
||||||
&database.Target{},
|
|
||||||
&database.User{},
|
|
||||||
&database.Setting{},
|
|
||||||
))
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
@@ -99,7 +81,7 @@ func iDBManager(
|
|||||||
) *database.WebhookDBManager {
|
) *database.WebhookDBManager {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return database.NewTestWebhookDBManager(t.TempDir())
|
return databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||||
}
|
}
|
||||||
|
|
||||||
func iSeedWebhookDB(
|
func iSeedWebhookDB(
|
||||||
@@ -670,6 +652,11 @@ func TestRecoverPendingDeliveries(t *testing.T) {
|
|||||||
t, s.WebhookDB, s.WebhookID, targetID, 3,
|
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(
|
s.Engine.ExportRecoverPendingDeliveries(
|
||||||
context.Background(), s.WebhookDB,
|
context.Background(), s.WebhookDB,
|
||||||
s.WebhookID,
|
s.WebhookID,
|
||||||
@@ -684,6 +671,8 @@ func TestRecoverPendingDeliveries(t *testing.T) {
|
|||||||
database.TargetTypeLog,
|
database.TargetTypeLog,
|
||||||
task.TargetType,
|
task.TargetType,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, eventQuery, task.RawQuery)
|
||||||
case <-time.After(2 * time.Second):
|
case <-time.After(2 * time.Second):
|
||||||
t.Fatalf("expected task %d", i)
|
t.Fatalf("expected task %d", i)
|
||||||
}
|
}
|
||||||
@@ -1134,6 +1123,85 @@ func TestRecoverInFlight_WithPendingDeliveries(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
|
||||||
|
// whose database file is gone, after a partial restore say. Restart
|
||||||
|
// recovery opens every webhook's database, so the empty one made in its
|
||||||
|
// place is reported at start, naming the file
|
||||||
|
// (https://git.eeqj.de/sneak/webhooker/issues/290).
|
||||||
|
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mainDB := iMainDB(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
iCreateWebhook(t, mainDB.DB(), webhookID, "lost-database")
|
||||||
|
|
||||||
|
var logs bytes.Buffer
|
||||||
|
|
||||||
|
dbMgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||||
|
t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
|
||||||
|
)
|
||||||
|
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
||||||
|
|
||||||
|
engine := delivery.NewTestEngineWithDB(
|
||||||
|
mainDB, dbMgr,
|
||||||
|
slog.New(slog.DiscardHandler),
|
||||||
|
&http.Client{Timeout: 5 * time.Second}, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine.ExportRecoverInFlight(context.Background())
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, logs.String(),
|
||||||
|
`level=WARN msg="created a new, empty database" webhook_id=`+
|
||||||
|
webhookID+" path="+dbMgr.DBPath(webhookID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
|
||||||
|
// webhook deleted from the web interface while restart recovery runs.
|
||||||
|
// Its database file is gone, and recovery must not create it again.
|
||||||
|
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mainDB := iMainDB(t)
|
||||||
|
webhookID := uuid.New().String()
|
||||||
|
iCreateWebhook(t, mainDB.DB(), webhookID, "deleted-during-recovery")
|
||||||
|
|
||||||
|
// The first query to return is recovery's read of the list of
|
||||||
|
// webhooks. Deleting the webhook right after it puts the delete
|
||||||
|
// between that read and the opening of the webhook's database.
|
||||||
|
deleted := false
|
||||||
|
|
||||||
|
require.NoError(t, mainDB.DB().Callback().Query().After("gorm:query").
|
||||||
|
Register("delete-after-list", func(*gorm.DB) {
|
||||||
|
if deleted {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted = true
|
||||||
|
|
||||||
|
require.NoError(t, mainDB.DB().Delete(
|
||||||
|
&database.Webhook{}, "id = ?", webhookID,
|
||||||
|
).Error)
|
||||||
|
}))
|
||||||
|
|
||||||
|
dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir())
|
||||||
|
t.Cleanup(func() { _ = dbMgr.CloseAll() })
|
||||||
|
|
||||||
|
engine := delivery.NewTestEngineWithDB(
|
||||||
|
mainDB, dbMgr,
|
||||||
|
slog.New(slog.DiscardHandler),
|
||||||
|
&http.Client{Timeout: 5 * time.Second}, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
engine.ExportRecoverInFlight(context.Background())
|
||||||
|
|
||||||
|
require.True(t, deleted)
|
||||||
|
assert.False(t, dbMgr.DBExists(webhookID))
|
||||||
|
}
|
||||||
|
|
||||||
// --- HTTP Config with custom headers ---
|
// --- HTTP Config with custom headers ---
|
||||||
|
|
||||||
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
|
||||||
@@ -1425,6 +1493,32 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
|
||||||
|
// failed for an invalid config only once the reason is recorded.
|
||||||
|
// Unrecorded, it stays pending, where the sweep finds it again.
|
||||||
|
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := testWebhookDB(t)
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
event, del := iSeedEventAndDelivery(
|
||||||
|
t, db, `{"config":"invalid"}`, "",
|
||||||
|
)
|
||||||
|
|
||||||
|
task, d := iHTTPTaskAndDelivery(
|
||||||
|
event, del, "bad-config", `not-json`, 0, 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, db.Exec("drop table delivery_results").Error)
|
||||||
|
|
||||||
|
e.ExportDeliverHTTP(context.TODO(), db, d, task)
|
||||||
|
|
||||||
|
iAssertStatus(t, db, del.ID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Notify batching ---
|
// --- Notify batching ---
|
||||||
|
|
||||||
func TestNotify_MultipleTasks(t *testing.T) {
|
func TestNotify_MultipleTasks(t *testing.T) {
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
@@ -25,6 +27,7 @@ import (
|
|||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
"sneak.berlin/go/webhooker/internal/metrics"
|
"sneak.berlin/go/webhooker/internal/metrics"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,7 +52,8 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
db, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -1015,6 +1019,62 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
|
||||||
|
// reads the state of an http or a slack target's circuit breaker, with
|
||||||
|
// what is left of its cooldown while it is open, and no cooldown while
|
||||||
|
// it is half-open, once it closes, or for a target with no breaker.
|
||||||
|
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
httpID := uuid.New().String()
|
||||||
|
slackID := uuid.New().String()
|
||||||
|
|
||||||
|
state, cooldown := e.StateAndCooldown(httpID)
|
||||||
|
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
|
||||||
|
assert.Zero(t, cooldown, "no breaker")
|
||||||
|
|
||||||
|
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
|
||||||
|
e.ExportSetCircuitBreaker(httpID, httpCB)
|
||||||
|
|
||||||
|
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
|
||||||
|
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
|
||||||
|
|
||||||
|
httpCB.RecordFailure()
|
||||||
|
slackCB.RecordFailure()
|
||||||
|
|
||||||
|
for _, id := range []string{httpID, slackID} {
|
||||||
|
state, cooldown := e.StateAndCooldown(id)
|
||||||
|
assert.Equal(t, delivery.CircuitOpen, state)
|
||||||
|
assert.Greater(t, cooldown, 59*time.Minute)
|
||||||
|
assert.LessOrEqual(t, cooldown, time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpCB.RecordSuccess()
|
||||||
|
slackCB.RecordSuccess()
|
||||||
|
|
||||||
|
for _, id := range []string{httpID, slackID} {
|
||||||
|
state, cooldown := e.StateAndCooldown(id)
|
||||||
|
assert.Equal(t, delivery.CircuitClosed, state, "closed")
|
||||||
|
assert.Zero(t, cooldown, "closed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A breaker with no cooldown goes half-open on the first Allow
|
||||||
|
// after it trips, letting that one delivery through to test the
|
||||||
|
// target.
|
||||||
|
halfOpenID := uuid.New().String()
|
||||||
|
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
|
||||||
|
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
|
||||||
|
|
||||||
|
halfOpenCB.RecordFailure()
|
||||||
|
require.True(t, halfOpenCB.Allow())
|
||||||
|
|
||||||
|
state, cooldown = e.StateAndCooldown(halfOpenID)
|
||||||
|
assert.Equal(t, delivery.CircuitHalfOpen, state)
|
||||||
|
assert.Zero(t, cooldown, "half-open")
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseHTTPConfig_Valid(t *testing.T) {
|
func TestParseHTTPConfig_Valid(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1056,6 +1116,21 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseHTTPConfig_Undecodable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
_, err := e.ExportParseHTTPConfig(
|
||||||
|
`{"url":"https://example.com/hook","timeout":"soon"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Error(t, err,
|
||||||
|
"config that does not decode should return error, "+
|
||||||
|
"even when the part that did names a URL",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func TestScheduleRetry_SendsToRetryChannel(
|
func TestScheduleRetry_SendsToRetryChannel(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -1241,6 +1316,33 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A response that ends before the length it announced is an error, not
|
||||||
|
// a short body.
|
||||||
|
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(
|
||||||
|
http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.Header().Set("Content-Length", "100")
|
||||||
|
_, _ = w.Write([]byte("cut short"))
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
_, body, _, err := e.ExportDoHTTPRequest(
|
||||||
|
context.TODO(),
|
||||||
|
&delivery.HTTPTargetConfig{URL: ts.URL},
|
||||||
|
&database.Event{},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
|
||||||
|
assert.Empty(t, body)
|
||||||
|
}
|
||||||
|
|
||||||
// The event's stored inbound headers carry the same Content-Type the
|
// The event's stored inbound headers carry the same Content-Type the
|
||||||
// receiver saved as the event's ContentType, so a delivery could send
|
// receiver saved as the event's ContentType, so a delivery could send
|
||||||
// it twice. It must go out exactly once, with a Content-Type configured
|
// it twice. It must go out exactly once, with a Content-Type configured
|
||||||
@@ -1317,6 +1419,34 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored inbound headers that do not decode forward nothing, not the
|
||||||
|
// part of them that happened to decode.
|
||||||
|
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodPost,
|
||||||
|
"https://target.example.com/hook",
|
||||||
|
http.NoBody,
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
names := delivery.ExportApplyRequestHeaders(
|
||||||
|
req,
|
||||||
|
&database.Event{
|
||||||
|
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
|
||||||
|
},
|
||||||
|
&delivery.HTTPTargetConfig{},
|
||||||
|
"webhooker/dev",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Empty(t, names)
|
||||||
|
assert.Empty(t, req.Header.Get("X-Custom"))
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessDelivery_RoutesToCorrectHandler(
|
func TestProcessDelivery_RoutesToCorrectHandler(
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
) {
|
) {
|
||||||
@@ -1861,6 +1991,10 @@ func assertLogLineComplete(
|
|||||||
"log line must contain the full request headers",
|
"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,
|
assert.Contains(t, out, event.EntrypointID,
|
||||||
"log line must contain the entrypoint id",
|
"log line must contain the entrypoint id",
|
||||||
)
|
)
|
||||||
@@ -1883,6 +2017,7 @@ func TestDeliverLog_LogsFullContent(t *testing.T) {
|
|||||||
event := seedEvent(
|
event := seedEvent(
|
||||||
t, db, `{"log-body-marker":"abc123"}`,
|
t, db, `{"log-body-marker":"abc123"}`,
|
||||||
)
|
)
|
||||||
|
event.RawQuery = eventQuery
|
||||||
|
|
||||||
dlv := seedDelivery(
|
dlv := seedDelivery(
|
||||||
t, db, event.ID, uuid.New().String(),
|
t, db, event.ID, uuid.New().String(),
|
||||||
|
|||||||
@@ -212,6 +212,14 @@ func (e *Engine) ExportSetCircuitBreaker(
|
|||||||
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
e.httpTarget.circuitBreakers.Store(targetID, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
|
||||||
|
// slack target.
|
||||||
|
func (e *Engine) ExportSetSlackCircuitBreaker(
|
||||||
|
targetID string, cb *CircuitBreaker,
|
||||||
|
) {
|
||||||
|
e.slackTarget.circuitBreakers.Store(targetID, cb)
|
||||||
|
}
|
||||||
|
|
||||||
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
// ExportParseHTTPConfig exposes parseHTTPConfig.
|
||||||
func (e *Engine) ExportParseHTTPConfig(
|
func (e *Engine) ExportParseHTTPConfig(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
@@ -493,23 +501,32 @@ func NewExportArchiveWriter(
|
|||||||
return &ExportArchiveWriter{w: w}
|
return &ExportArchiveWriter{w: w}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write archives a row through the writer.
|
// Write archives a row through the writer, into the file named
|
||||||
|
// without a period.
|
||||||
func (e *ExportArchiveWriter) Write(
|
func (e *ExportArchiveWriter) Write(
|
||||||
row ExportArchivedEvent, expiry time.Duration,
|
row ExportArchivedEvent, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.write(row, expiry)
|
return e.w.write(row, expiry, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WritePeriod archives a row through the writer, into the file for
|
||||||
|
// period.
|
||||||
|
func (e *ExportArchiveWriter) WritePeriod(
|
||||||
|
row ExportArchivedEvent, expiry time.Duration, period string,
|
||||||
|
) error {
|
||||||
|
return e.w.write(row, expiry, period)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Open opens the archive file, pruning when expiry is positive.
|
// Open opens the archive file, pruning when expiry is positive.
|
||||||
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
|
||||||
return e.w.open(expiry)
|
return e.w.open(e.w.path, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reopen closes and reopens the archive file.
|
// Reopen closes and reopens the archive file.
|
||||||
func (e *ExportArchiveWriter) Reopen(
|
func (e *ExportArchiveWriter) Reopen(
|
||||||
expiry time.Duration,
|
expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.reopen(expiry)
|
return e.w.reopen(e.w.path, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetNow replaces the clock the writer measures its reopen
|
// SetNow replaces the clock the writer measures its reopen
|
||||||
@@ -539,7 +556,7 @@ func (e *ExportArchiveWriter) Path() string {
|
|||||||
func (e *ExportArchiveWriter) OpenExisting(
|
func (e *ExportArchiveWriter) OpenExisting(
|
||||||
expiry time.Duration,
|
expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
return e.w.openMode(archiveModeExisting, expiry)
|
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SweepExpired runs an idle sweep of the archive.
|
// SweepExpired runs an idle sweep of the archive.
|
||||||
|
|||||||
@@ -48,8 +48,9 @@ func fSweepSetup(
|
|||||||
//
|
//
|
||||||
// Every caller drives the dispatch paths synchronously and has already
|
// Every caller drives the dispatch paths synchronously and has already
|
||||||
// waited for them to return, so anything they queued is in the channel
|
// 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
|
// by now, and nothing is waited for. A timer here would race the queued
|
||||||
// kept small because one of these tests runs the drain forty times.
|
// 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 {
|
func fDrain(e *delivery.Engine) []delivery.Task {
|
||||||
var out []delivery.Task
|
var out []delivery.Task
|
||||||
|
|
||||||
@@ -59,7 +60,7 @@ func fDrain(e *delivery.Engine) []delivery.Task {
|
|||||||
out = append(out, task)
|
out = append(out, task)
|
||||||
case task := <-e.ExportRetryCh():
|
case task := <-e.ExportRetryCh():
|
||||||
out = append(out, task)
|
out = append(out, task)
|
||||||
case <-time.After(25 * time.Millisecond):
|
default:
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -14,11 +13,9 @@ import (
|
|||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/gormlog"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// qdAggregateMarker identifies the queue-depth aggregate in the
|
// 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
|
// qdMainDB opens a main database whose GORM logger is the service's
|
||||||
// adapter, writing through log.
|
// 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()
|
t.Helper()
|
||||||
|
|
||||||
sqlDB, err := database.OpenSQLite(
|
db, err := database.Open(t.TempDir(), log)
|
||||||
filepath.Join(t.TempDir(), "main-gormlog.db"),
|
|
||||||
database.SQLiteModeCreate,
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
db, err := gorm.Open(
|
|
||||||
sqlite.Dialector{Conn: sqlDB},
|
|
||||||
&gorm.Config{Logger: gormlog.New(log)},
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.NoError(t, db.AutoMigrate(
|
|
||||||
&database.Webhook{},
|
|
||||||
&database.Target{},
|
|
||||||
))
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
@@ -106,18 +89,18 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
|
|||||||
))
|
))
|
||||||
|
|
||||||
mainDB := qdMainDB(t, log)
|
mainDB := qdMainDB(t, log)
|
||||||
dbMgr := database.NewTestWebhookDBManagerWithLogger(
|
dbMgr := databasetest.NewWebhookDBManagerWithLogger(
|
||||||
t.TempDir(), log,
|
t, t.TempDir(), log,
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
webhookID := uuid.New().String()
|
||||||
webhookDB := iSeedWebhookDB(t, dbMgr, webhookID)
|
webhookDB := iSeedWebhookDB(t, dbMgr, webhookID)
|
||||||
|
|
||||||
iCreateWebhook(t, mainDB, webhookID, "queue-depth-gormlog")
|
iCreateWebhook(t, mainDB.DB(), webhookID, "queue-depth-gormlog")
|
||||||
|
|
||||||
targetID := uuid.New().String()
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
iCreateTarget(t, mainDB, targetID, webhookID,
|
iCreateTarget(t, mainDB.DB(), targetID, webhookID,
|
||||||
"queue-depth-gormlog-target", database.TargetTypeHTTP,
|
"queue-depth-gormlog-target", database.TargetTypeHTTP,
|
||||||
iHTTPConfig("https://example.com/hook"), 3,
|
iHTTPConfig("https://example.com/hook"), 3,
|
||||||
)
|
)
|
||||||
@@ -136,7 +119,7 @@ func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
engine := delivery.NewTestEngineWithDB(
|
engine := delivery.NewTestEngineWithDB(
|
||||||
database.NewTestDatabase(mainDB),
|
mainDB,
|
||||||
dbMgr,
|
dbMgr,
|
||||||
log,
|
log,
|
||||||
&http.Client{Timeout: 5 * time.Second},
|
&http.Client{Timeout: 5 * time.Second},
|
||||||
|
|||||||
@@ -376,3 +376,97 @@ func TestFailedResultWriteLeavesDeliveryRecoverable(
|
|||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
|
||||||
|
// rule for a target with retries: whatever the receiver answered, the
|
||||||
|
// delivery stays pending and no retry is scheduled. The circuit breaker
|
||||||
|
// still learns the answer, because it describes the target's health,
|
||||||
|
// not the database's.
|
||||||
|
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The "send succeeded" case starts with the breaker tripped open,
|
||||||
|
// so the delivery goes out as its probe and only a recorded
|
||||||
|
// success closes it again.
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
answer int
|
||||||
|
tripped bool
|
||||||
|
wantBreaker delivery.CircuitState
|
||||||
|
}{
|
||||||
|
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
|
||||||
|
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s := newISetup(t)
|
||||||
|
targetID := uuid.New().String()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(tc.answer)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
event := iSeedEvent(
|
||||||
|
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
d := iSeedDelivery(
|
||||||
|
t, s.WebhookDB, event.ID, targetID,
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t,
|
||||||
|
s.WebhookDB.Exec("drop table delivery_results").Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
// A single failure opens this breaker, and with no
|
||||||
|
// cooldown an open breaker lets the next delivery
|
||||||
|
// through as a probe.
|
||||||
|
cb := delivery.NewTestCircuitBreaker(1, 0)
|
||||||
|
if tc.tripped {
|
||||||
|
cb.RecordFailure()
|
||||||
|
}
|
||||||
|
|
||||||
|
s.Engine.ExportSetCircuitBreaker(targetID, cb)
|
||||||
|
|
||||||
|
full := &database.Delivery{
|
||||||
|
EventID: event.ID,
|
||||||
|
TargetID: targetID,
|
||||||
|
Status: database.DeliveryStatusPending,
|
||||||
|
Event: event,
|
||||||
|
Target: database.Target{
|
||||||
|
Name: "unwritable",
|
||||||
|
Type: database.TargetTypeHTTP,
|
||||||
|
Config: iHTTPConfig(ts.URL),
|
||||||
|
MaxRetries: 3,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
full.ID = d.ID
|
||||||
|
|
||||||
|
sched := &recordingScheduler{}
|
||||||
|
|
||||||
|
s.Engine.ExportDeliverHTTPWithScheduler(
|
||||||
|
context.Background(), s.WebhookDB, full,
|
||||||
|
&delivery.Task{
|
||||||
|
DeliveryID: d.ID,
|
||||||
|
TargetID: targetID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
},
|
||||||
|
sched,
|
||||||
|
)
|
||||||
|
|
||||||
|
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
|
||||||
|
assert.Empty(t, sched.delays, "no retry may be scheduled")
|
||||||
|
assert.Equal(t, tc.wantBreaker, cb.State())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,7 @@ func (e *Engine) initTargets(client *http.Client) {
|
|||||||
dbT := &databaseTarget{eng: e}
|
dbT := &databaseTarget{eng: e}
|
||||||
|
|
||||||
e.httpTarget = httpT
|
e.httpTarget = httpT
|
||||||
|
e.slackTarget = slackT
|
||||||
e.dbTarget = dbT
|
e.dbTarget = dbT
|
||||||
|
|
||||||
e.targets = map[database.TargetType]Target{
|
e.targets = map[database.TargetType]Target{
|
||||||
|
|||||||
@@ -39,8 +39,13 @@ type TargetConfigForm struct {
|
|||||||
// Timeout is the HTTP target's per-request timeout in seconds,
|
// Timeout is the HTTP target's per-request timeout in seconds,
|
||||||
// empty when unset.
|
// empty when unset.
|
||||||
Timeout string
|
Timeout string
|
||||||
|
// ForwardQuery is the HTTP target's setting that passes each
|
||||||
|
// event's query string on to it.
|
||||||
|
ForwardQuery bool
|
||||||
// Expiry is the database (archive) target's row expiry.
|
// Expiry is the database (archive) target's row expiry.
|
||||||
Expiry string
|
Expiry string
|
||||||
|
// Rotation is the database (archive) target's rotation.
|
||||||
|
Rotation string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTargetConfigForm parses a target's stored configuration into
|
// NewTargetConfigForm parses a target's stored configuration into
|
||||||
@@ -65,6 +70,7 @@ func NewTargetConfigForm(
|
|||||||
URL: cfg.URL,
|
URL: cfg.URL,
|
||||||
Headers: FormatTargetHeaders(cfg.Headers),
|
Headers: FormatTargetHeaders(cfg.Headers),
|
||||||
Timeout: FormatTargetTimeout(cfg.Timeout),
|
Timeout: FormatTargetTimeout(cfg.Timeout),
|
||||||
|
ForwardQuery: cfg.ForwardQuery,
|
||||||
}, nil
|
}, nil
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
cfg, err := parseSlackConfig(t.Config)
|
cfg, err := parseSlackConfig(t.Config)
|
||||||
@@ -85,11 +91,13 @@ func NewTargetConfigForm(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseConfigForm parses an archive target's optional expiry.
|
// databaseConfigForm parses an archive target's optional expiry and
|
||||||
// An absent or empty configuration is the keep-forever default and
|
// rotation. An absent, empty or never expiry yields an empty expiry,
|
||||||
// yields an empty field, so re-saving the form unchanged stores the
|
// on which the edit form starts at never; saving it unchanged stores
|
||||||
// same empty configuration it started with. An expiry that is set
|
// never, which means the same as an empty expiry. An absent rotation
|
||||||
// but not a valid duration is an error, not a blank field.
|
// 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(
|
func databaseConfigForm(
|
||||||
configJSON string,
|
configJSON string,
|
||||||
) (TargetConfigForm, error) {
|
) (TargetConfigForm, error) {
|
||||||
@@ -106,8 +114,15 @@ func databaseConfigForm(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = ValidateArchiveRotation(cfg.Rotation)
|
||||||
|
if err != nil {
|
||||||
|
return TargetConfigForm{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
form := TargetConfigForm{Rotation: cfg.Rotation}
|
||||||
|
|
||||||
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
|
||||||
return TargetConfigForm{}, nil
|
return form, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
err = ValidateArchiveExpiry(cfg.Expiry)
|
err = ValidateArchiveExpiry(cfg.Expiry)
|
||||||
@@ -115,5 +130,7 @@ func databaseConfigForm(
|
|||||||
return TargetConfigForm{}, err
|
return TargetConfigForm{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return TargetConfigForm{Expiry: cfg.Expiry}, nil
|
form.Expiry = cfg.Expiry
|
||||||
|
|
||||||
|
return form, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package delivery
|
package delivery
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
@@ -97,7 +97,7 @@ func targetConfigFields(
|
|||||||
) []ConfigField {
|
) []ConfigField {
|
||||||
switch t.Type {
|
switch t.Type {
|
||||||
case database.TargetTypeSlack:
|
case database.TargetTypeSlack:
|
||||||
return slackConfigFields(t.Config)
|
return slackConfigFields(t)
|
||||||
case database.TargetTypeHTTP:
|
case database.TargetTypeHTTP:
|
||||||
return httpConfigFields(t)
|
return httpConfigFields(t)
|
||||||
case database.TargetTypeDatabase:
|
case database.TargetTypeDatabase:
|
||||||
@@ -119,10 +119,11 @@ func unavailableConfigFields() []ConfigField {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// slackConfigFields describes a Slack target. Only the masked
|
// slackConfigFields describes a Slack target: its masked
|
||||||
// webhook URL is shown; the full URL is the credential.
|
// webhook URL and its retry count. Only the masked URL is
|
||||||
func slackConfigFields(configJSON string) []ConfigField {
|
// shown; the full URL is the credential.
|
||||||
cfg, err := parseSlackConfig(configJSON)
|
func slackConfigFields(t *database.Target) []ConfigField {
|
||||||
|
cfg, err := parseSlackConfig(t.Config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ func slackConfigFields(configJSON string) []ConfigField {
|
|||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Webhook URL",
|
Label: "Webhook URL",
|
||||||
Value: cfg.MaskedWebhookURL(),
|
Value: cfg.MaskedWebhookURL(),
|
||||||
}}
|
}, maxRetriesField(t)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpConfigFields describes an HTTP target: its destination
|
// httpConfigFields describes an HTTP target: its destination
|
||||||
@@ -170,63 +171,97 @@ func httpConfigFields(t *database.Target) []ConfigField {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return append(fields, retryFields(t)...)
|
if cfg.ForwardQuery {
|
||||||
}
|
|
||||||
|
|
||||||
// retryFields describes a target's retry settings, which live
|
|
||||||
// on the target row rather than in its configuration blob.
|
|
||||||
func retryFields(t *database.Target) []ConfigField {
|
|
||||||
retries := strconv.Itoa(t.MaxRetries)
|
|
||||||
if t.MaxRetries == 0 {
|
|
||||||
retries += " (fire-and-forget)"
|
|
||||||
}
|
|
||||||
|
|
||||||
fields := []ConfigField{{
|
|
||||||
Label: "Max Retries",
|
|
||||||
Value: retries,
|
|
||||||
}}
|
|
||||||
|
|
||||||
if t.MaxQueueSize > 0 {
|
|
||||||
fields = append(fields, ConfigField{
|
fields = append(fields, ConfigField{
|
||||||
Label: "Max Queue Size",
|
Label: "Query string",
|
||||||
Value: strconv.Itoa(t.MaxQueueSize),
|
Value: "passed on to this target",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fields = append(fields, maxRetriesField(t))
|
||||||
|
|
||||||
return fields
|
return fields
|
||||||
}
|
}
|
||||||
|
|
||||||
// databaseConfigFields describes an archive target. Its
|
// maxRetriesField describes a target's retry count, which lives
|
||||||
// configuration is optional, and an absent or empty expiry
|
// on the target row rather than in its configuration blob. A
|
||||||
// means the archive is kept forever. An expiry that is set
|
// stored 0 makes a single attempt, so it is shown as 1.
|
||||||
// but not a valid duration is reported as unavailable rather
|
func maxRetriesField(t *database.Target) ConfigField {
|
||||||
// than echoed back.
|
attempts := strconv.Itoa(t.MaxRetries)
|
||||||
|
if t.MaxRetries == 0 {
|
||||||
|
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return ConfigField{
|
||||||
|
Label: "Delivery attempts",
|
||||||
|
Value: attempts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// databaseConfigFields describes an archive target by its
|
||||||
|
// expiry in plain units, such as "30 days", or "never" when
|
||||||
|
// the archive is kept forever, and by its rotation. An expiry
|
||||||
|
// that is set but not a valid duration, or a rotation that is
|
||||||
|
// not one of the four, is reported as unavailable rather than
|
||||||
|
// echoed back.
|
||||||
func databaseConfigFields(configJSON string) []ConfigField {
|
func databaseConfigFields(configJSON string) []ConfigField {
|
||||||
expiry := archiveExpiryNever
|
expiry, err := parseArchiveExpiry(configJSON)
|
||||||
|
|
||||||
if configJSON != "" {
|
|
||||||
var cfg databaseTargetConfig
|
|
||||||
|
|
||||||
err := json.Unmarshal([]byte(configJSON), &cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Expiry != "" {
|
rotation, err := parseArchiveRotation(configJSON)
|
||||||
if ValidateArchiveExpiry(cfg.Expiry) != nil {
|
if err != nil {
|
||||||
return unavailableConfigFields()
|
return unavailableConfigFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
expiry = cfg.Expiry
|
value := archiveExpiryNever
|
||||||
}
|
if expiry > 0 {
|
||||||
|
value = plainDuration(expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Archive Expiry",
|
Label: "Archive expiry",
|
||||||
Value: expiry,
|
Value: value,
|
||||||
|
}, {
|
||||||
|
Label: "Archive rotation",
|
||||||
|
Value: rotation,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plainDuration writes a positive duration as a count of the
|
||||||
|
// largest whole unit it divides into: "30 days", "12 hours",
|
||||||
|
// "1 minute". A duration with a fraction of a second is
|
||||||
|
// written as Go writes it.
|
||||||
|
func plainDuration(d time.Duration) string {
|
||||||
|
const day = 24 * time.Hour
|
||||||
|
|
||||||
|
units := []struct {
|
||||||
|
size time.Duration
|
||||||
|
name string
|
||||||
|
}{
|
||||||
|
{day, "day"},
|
||||||
|
{time.Hour, "hour"},
|
||||||
|
{time.Minute, "minute"},
|
||||||
|
{time.Second, "second"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, unit := range units {
|
||||||
|
if d%unit.size != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
count := int64(d / unit.size)
|
||||||
|
if count == 1 {
|
||||||
|
return "1 " + unit.name
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%d %ss", count, unit.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return d.String()
|
||||||
|
}
|
||||||
|
|
||||||
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
// MaskedWebhookURL returns the Slack webhook URL reduced to
|
||||||
// its scheme and host, with the path, query and any userinfo
|
// its scheme and host, with the path, query and any userinfo
|
||||||
// elided. The path segments are the credential, so none of
|
// elided. The path segments are the credential, so none of
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
|
viewMaxRetries = "Delivery attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -157,9 +158,7 @@ func TestNewTargetViews_DeletedTarget(t *testing.T) {
|
|||||||
t, slackTargetName+" (deleted)", view.DisplayName(),
|
t, slackTargetName+" (deleted)", view.DisplayName(),
|
||||||
)
|
)
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t, viewFor(t, slackTarget()).Config, view.Config,
|
||||||
map[string]string{"Webhook URL": slackMaskedURL},
|
|
||||||
fieldMap(view.Config),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +188,30 @@ func TestNewTargetViews_Slack(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{"Webhook URL": slackMaskedURL},
|
map[string]string{
|
||||||
|
"Webhook URL": slackMaskedURL,
|
||||||
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||||
|
},
|
||||||
|
fieldMap(view.Config),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewTargetViews_SlackRetries proves a Slack target shows
|
||||||
|
// its retry count the same way an HTTP target does.
|
||||||
|
func TestNewTargetViews_SlackRetries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
target := slackTarget()
|
||||||
|
target.MaxRetries = 2
|
||||||
|
|
||||||
|
view := viewFor(t, target)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t,
|
||||||
|
map[string]string{
|
||||||
|
"Webhook URL": slackMaskedURL,
|
||||||
|
viewMaxRetries: "2",
|
||||||
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -201,9 +223,9 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
Type: database.TargetTypeHTTP,
|
Type: database.TargetTypeHTTP,
|
||||||
Config: `{"url":"` + viewExampleHook + `",` +
|
Config: `{"url":"` + viewExampleHook + `",` +
|
||||||
`"timeout":30,` +
|
`"timeout":30,` +
|
||||||
`"headers":{"Authorization":"Bearer sekrit"}}`,
|
`"headers":{"Authorization":"Bearer sekrit"},` +
|
||||||
|
`"forwardQuery":true}`,
|
||||||
MaxRetries: 5,
|
MaxRetries: 5,
|
||||||
MaxQueueSize: 100,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
fields := fieldMap(view.Config)
|
fields := fieldMap(view.Config)
|
||||||
@@ -214,8 +236,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
|
|||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Timeout": "30s",
|
"Timeout": "30s",
|
||||||
"Headers": "1 configured",
|
"Headers": "1 configured",
|
||||||
"Max Retries": "5",
|
"Query string": "passed on to this target",
|
||||||
"Max Queue Size": "100",
|
viewMaxRetries: "5",
|
||||||
},
|
},
|
||||||
fields,
|
fields,
|
||||||
)
|
)
|
||||||
@@ -238,7 +260,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
|
|||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Destination URL": viewMaskedOrigin,
|
"Destination URL": viewMaskedOrigin,
|
||||||
"Max Retries": "0 (fire-and-forget)",
|
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -281,14 +303,20 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
"empty config": {config: "", want: viewExpiryNever},
|
"empty config": {config: "", want: viewExpiryNever},
|
||||||
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
"empty expiry": {config: `{}`, want: viewExpiryNever},
|
||||||
"explicit": {
|
|
||||||
config: `{"expiry":"720h"}`,
|
|
||||||
want: "720h",
|
|
||||||
},
|
|
||||||
"never literal": {
|
"never literal": {
|
||||||
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
config: `{"expiry":"` + viewExpiryNever + `"}`,
|
||||||
want: viewExpiryNever,
|
want: viewExpiryNever,
|
||||||
},
|
},
|
||||||
|
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
|
||||||
|
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
|
||||||
|
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
|
||||||
|
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
|
||||||
|
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
|
||||||
|
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
|
||||||
|
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
|
||||||
|
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
|
||||||
|
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
|
||||||
|
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, tc := range tests {
|
for name, tc := range tests {
|
||||||
@@ -302,13 +330,49 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{"Archive Expiry": tc.want},
|
map[string]string{
|
||||||
|
"Archive expiry": tc.want,
|
||||||
|
"Archive rotation": rotationNone,
|
||||||
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestNewTargetViews_DatabaseRotation proves the target list shows a
|
||||||
|
// database target's rotation, none when it has none stored.
|
||||||
|
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each stored config, and the rotation the list shows for it.
|
||||||
|
tests := map[string]string{
|
||||||
|
"": rotationNone,
|
||||||
|
`{"rotation":""}`: rotationNone,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, rotation := range []string{
|
||||||
|
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
||||||
|
} {
|
||||||
|
tests[`{"rotation":"`+rotation+`"}`] = rotation
|
||||||
|
}
|
||||||
|
|
||||||
|
for config, want := range tests {
|
||||||
|
t.Run(config, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
view := viewFor(t, database.Target{
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Config: config,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, want, fieldMap(view.Config)["Archive rotation"],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewTargetViews_Log(t *testing.T) {
|
func TestNewTargetViews_Log(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -356,6 +420,10 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"a fortnight"}`,
|
Config: `{"expiry":"a fortnight"}`,
|
||||||
},
|
},
|
||||||
|
"invalid archive rotation": {
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Config: weeklyConfig,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for name, target := range tests {
|
for name, target := range tests {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package delivery
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -21,7 +20,8 @@ const archiveNameMaxLen = 40
|
|||||||
// from the per-webhook event database. The event is already
|
// from the per-webhook event database. The event is already
|
||||||
// persisted in the per-webhook event DB by the time delivery runs;
|
// persisted in the per-webhook event DB by the time delivery runs;
|
||||||
// the database target additionally writes a durable long-term copy
|
// the database target additionally writes a durable long-term copy
|
||||||
// into the file ArchiveFileName names and then records a single
|
// into the file ArchiveFileName names, with a period added when the
|
||||||
|
// target rotates (see archivePeriodPath), and then records a single
|
||||||
// attempt whose outcome reflects whether the archive write
|
// attempt whose outcome reflects whether the archive write
|
||||||
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
|
||||||
// and expiry semantics.
|
// and expiry semantics.
|
||||||
@@ -147,8 +147,9 @@ func (t *databaseTarget) Deliver(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// archive writes the full event as a row into the target's
|
// archive writes the full event as a row into the target's
|
||||||
// archive database, honouring the optional per-target expiry
|
// archive database, honouring the optional per-target expiry and
|
||||||
// parsed from the target config JSON.
|
// 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 {
|
func (t *databaseTarget) archive(d *database.Delivery) error {
|
||||||
webhookID := d.Event.WebhookID
|
webhookID := d.Event.WebhookID
|
||||||
if webhookID == "" {
|
if webhookID == "" {
|
||||||
@@ -160,6 +161,19 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
return err
|
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)
|
w, err := t.writerFor(d.TargetID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -170,12 +184,13 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
|
|||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: d.Event.EntrypointID,
|
EntrypointID: d.Event.EntrypointID,
|
||||||
Method: d.Event.Method,
|
Method: d.Event.Method,
|
||||||
|
RawQuery: d.Event.RawQuery,
|
||||||
Headers: d.Event.Headers,
|
Headers: d.Event.Headers,
|
||||||
Body: d.Event.Body,
|
Body: d.Event.Body,
|
||||||
ContentType: d.Event.ContentType,
|
ContentType: d.Event.ContentType,
|
||||||
}
|
}
|
||||||
|
|
||||||
return w.write(row, expiry)
|
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
|
||||||
}
|
}
|
||||||
|
|
||||||
// writerFor returns the archive writer for a database target,
|
// writerFor returns the archive writer for a database target,
|
||||||
@@ -276,11 +291,10 @@ func (t *databaseTarget) releaseSweepWriter(
|
|||||||
delete(t.writers, targetID)
|
delete(t.writers, targetID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// newWriter builds the writer for a database target's archive. The
|
// newWriter builds the writer for a database target's archive. Its
|
||||||
// file lives beside the webhook's event database in the data
|
// path is the one ArchivePath gives for the webhook and the target as
|
||||||
// directory and is named for the webhook and the target as the main
|
// the main database names them now; from then on only rename changes
|
||||||
// database has them now; from then on only rename changes the name
|
// the name the writer uses. It does not touch the archive files.
|
||||||
// the writer uses. It does not touch the archive file.
|
|
||||||
func (t *databaseTarget) newWriter(
|
func (t *databaseTarget) newWriter(
|
||||||
targetID string,
|
targetID string,
|
||||||
) (*archiveWriter, error) {
|
) (*archiveWriter, error) {
|
||||||
@@ -299,21 +313,19 @@ func (t *databaseTarget) newWriter(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Dir(t.eng.dbManager.DBPath(target.WebhookID))
|
w := newArchiveWriter(
|
||||||
name := ArchiveFileName(
|
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
|
||||||
target.Webhook.Name, target.Name, target.ID,
|
t.eng.log,
|
||||||
)
|
)
|
||||||
|
|
||||||
w := newArchiveWriter(filepath.Join(dir, name), t.eng.log)
|
|
||||||
w.webhookID = target.WebhookID
|
w.webhookID = target.WebhookID
|
||||||
|
|
||||||
return w, nil
|
return w, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename moves a database target's archive file to the name for
|
// rename moves every one of a database target's archive files to the
|
||||||
// webhookName and targetName. It goes through the target's writer,
|
// name for webhookName and targetName. It goes through the target's
|
||||||
// so the move holds the lock that writes and the idle sweep take,
|
// writer, so the move holds the lock that writes and the idle sweep
|
||||||
// and later writes use the new name.
|
// take, and later writes use the new name.
|
||||||
//
|
//
|
||||||
// The writer is created if there is none, and it stays cached. The
|
// 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
|
// handlers rename before they save the new name, so until the save
|
||||||
|
|||||||
@@ -85,6 +85,10 @@ type databaseTargetConfig struct {
|
|||||||
// archived rows are pruned, or "never" (the default) to
|
// archived rows are pruned, or "never" (the default) to
|
||||||
// keep them forever.
|
// keep them forever.
|
||||||
Expiry string `json:"expiry"`
|
Expiry string `json:"expiry"`
|
||||||
|
|
||||||
|
// Rotation is none (the default), monthly, daily or hourly: see
|
||||||
|
// archivePeriod.
|
||||||
|
Rotation string `json:"rotation"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// archivedEvent is one fully captured webhook event stored in a
|
// archivedEvent is one fully captured webhook event stored in a
|
||||||
@@ -97,6 +101,7 @@ type archivedEvent struct {
|
|||||||
WebhookID string
|
WebhookID string
|
||||||
EntrypointID string
|
EntrypointID string
|
||||||
Method string
|
Method string
|
||||||
|
RawQuery string
|
||||||
Headers string
|
Headers string
|
||||||
Body string
|
Body string
|
||||||
ContentType string
|
ContentType string
|
||||||
@@ -178,7 +183,7 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// archiveWriter owns one database target's archive SQLite file.
|
// archiveWriter owns one database target's archive SQLite files.
|
||||||
// It serialises writes, and after each write closes and reopens
|
// It serialises writes, and after each write closes and reopens
|
||||||
// the file (debounced to at most once per debounce window) so
|
// the file (debounced to at most once per debounce window) so
|
||||||
// an operator can move the file away for offline archiving. The
|
// an operator can move the file away for offline archiving. The
|
||||||
@@ -187,7 +192,15 @@ func ValidateArchiveExpiry(expiry string) error {
|
|||||||
// on every open.
|
// on every open.
|
||||||
type archiveWriter struct {
|
type archiveWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
|
// path is the target's archive file as ArchivePath names it. A
|
||||||
|
// target that rotates writes to the files archivePeriodPath names
|
||||||
|
// for path and a period instead.
|
||||||
path string
|
path string
|
||||||
|
|
||||||
|
// current is the file db is open on.
|
||||||
|
current string
|
||||||
|
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
debounce time.Duration
|
debounce time.Duration
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
@@ -236,12 +249,14 @@ func newArchiveWriter(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// write appends the event as a row, then applies the debounced
|
// write appends the event as a row to the archive file for period
|
||||||
// close/reopen. It recreates the archive file if it was moved
|
// (see archivePeriodPath), then applies the debounced close/reopen.
|
||||||
// or removed since the last open. A positive expiry prunes rows
|
// When period names a different file from the one open, the open one
|
||||||
// older than it on each (re)open.
|
// is closed first. It recreates the archive file if it was moved or
|
||||||
|
// removed since the last open. A positive expiry prunes rows older
|
||||||
|
// than it on each (re)open.
|
||||||
func (w *archiveWriter) write(
|
func (w *archiveWriter) write(
|
||||||
row archivedEvent, expiry time.Duration,
|
row archivedEvent, expiry time.Duration, period string,
|
||||||
) error {
|
) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
@@ -252,8 +267,10 @@ func (w *archiveWriter) write(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.db == nil || !fileExists(w.path) {
|
file := archivePeriodPath(w.path, period)
|
||||||
err := w.reopen(expiry)
|
|
||||||
|
if w.db == nil || w.current != file || !fileExists(file) {
|
||||||
|
err := w.reopen(file, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -264,41 +281,41 @@ func (w *archiveWriter) write(
|
|||||||
err := w.db.Create(&row).Error
|
err := w.db.Create(&row).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"archiving event to %s: %w", w.path, err,
|
"archiving event to %s: %w", file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if w.now().Sub(w.lastReopen) >= w.debounce {
|
if w.now().Sub(w.lastReopen) >= w.debounce {
|
||||||
return w.reopen(expiry)
|
return w.reopen(file, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// open opens (creating if missing) the archive file, migrates
|
// open opens (creating if missing) an archive file, migrates
|
||||||
// its schema, records the reopen time, and prunes expired rows
|
// its schema, records the reopen time, and prunes expired rows
|
||||||
// when expiry is positive.
|
// when expiry is positive.
|
||||||
func (w *archiveWriter) open(expiry time.Duration) error {
|
func (w *archiveWriter) open(file string, expiry time.Duration) error {
|
||||||
return w.openMode(archiveModeCreate, expiry)
|
return w.openMode(file, archiveModeCreate, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openMode opens the archive file with the given SQLite URI
|
// openMode opens an archive file with the given SQLite URI
|
||||||
// mode, migrates its schema, records the reopen time, and
|
// mode, migrates its schema, records the reopen time, and
|
||||||
// prunes expired rows when expiry is positive. The write path
|
// prunes expired rows when expiry is positive. The write path
|
||||||
// passes archiveModeCreate so a missing file is recreated; the
|
// passes archiveModeCreate so a missing file is recreated; the
|
||||||
// idle sweep passes archiveModeExisting so a missing file is an
|
// idle sweep passes archiveModeExisting so a missing file is an
|
||||||
// error rather than a newly conjured empty archive.
|
// error rather than a newly conjured empty archive.
|
||||||
func (w *archiveWriter) openMode(
|
func (w *archiveWriter) openMode(
|
||||||
mode string, expiry time.Duration,
|
file, mode string, expiry time.Duration,
|
||||||
) error {
|
) error {
|
||||||
// Opened through database.OpenSQLite so an archive file carries
|
// Opened through database.OpenSQLite so an archive file carries
|
||||||
// the same WAL journaling, busy timeout, immediate-transaction
|
// the same WAL journaling, busy timeout, immediate-transaction
|
||||||
// locking, and pool bounds as every other database file. See
|
// locking, and pool bounds as every other database file. See
|
||||||
// internal/database/sqlite_open.go.
|
// internal/database/sqlite_open.go.
|
||||||
sqlDB, err := database.OpenSQLite(w.path, mode)
|
sqlDB, err := database.OpenSQLite(file, mode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"opening archive database %s: %w", w.path, err,
|
"opening archive database %s: %w", file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +331,7 @@ func (w *archiveWriter) openMode(
|
|||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"connecting to archive database %s: %w",
|
"connecting to archive database %s: %w",
|
||||||
w.path, err,
|
file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,11 +340,12 @@ func (w *archiveWriter) openMode(
|
|||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"migrating archive database %s: %w", w.path, err,
|
"migrating archive database %s: %w", file, err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.db = gdb
|
w.db = gdb
|
||||||
|
w.current = file
|
||||||
w.lastReopen = w.now()
|
w.lastReopen = w.now()
|
||||||
w.reopens++
|
w.reopens++
|
||||||
|
|
||||||
@@ -338,12 +356,12 @@ func (w *archiveWriter) openMode(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// reopen closes any open handle and opens the file afresh. The
|
// reopen closes any open handle and opens file afresh. The
|
||||||
// fresh open recreates the file if it was moved away.
|
// fresh open recreates the file if it was moved away.
|
||||||
func (w *archiveWriter) reopen(expiry time.Duration) error {
|
func (w *archiveWriter) reopen(file string, expiry time.Duration) error {
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
return w.open(expiry)
|
return w.open(file, expiry)
|
||||||
}
|
}
|
||||||
|
|
||||||
// close closes the underlying handle, if any.
|
// close closes the underlying handle, if any.
|
||||||
@@ -360,22 +378,56 @@ func (w *archiveWriter) close() {
|
|||||||
w.db = nil
|
w.db = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweepExpired prunes an archive that may have gone idle, with
|
// sweepExpired prunes the target's archive files, which may have
|
||||||
// no write to trigger the usual on-reopen prune. It takes the
|
// gone idle, with no write to trigger the usual on-reopen prune. It
|
||||||
// writer's own mutex for the whole operation, so a sweep is
|
// lists the files under the writer's own mutex, then takes the mutex
|
||||||
// ordered against concurrent writes rather than reaching around
|
// again for one file at a time, so a write waits for at most one
|
||||||
// them to the file.
|
// file's prune, and each prune is ordered against concurrent writes
|
||||||
|
// rather than reaching around them to the file.
|
||||||
//
|
//
|
||||||
// It never creates the archive file: a missing file is skipped,
|
// It never creates an archive file: it prunes only the files
|
||||||
// and the reopen uses archiveModeExisting so SQLite itself
|
// archiveFiles lists, skips one that is gone by the time it is
|
||||||
// refuses to create one if the file disappears between the
|
// reached (moved away, or renamed since the listing), and opens each
|
||||||
// check and the open.
|
// with archiveModeExisting so SQLite itself refuses to create one if
|
||||||
|
// the file disappears between the check and the open. A file named
|
||||||
|
// for a period that the prune leaves empty is deleted.
|
||||||
//
|
//
|
||||||
// The archive is left CLOSED afterwards. An idle archive holding
|
// The archive is left CLOSED afterwards. An idle archive holding
|
||||||
// no handle is what keeps the operator's move-the-file-away
|
// no handle is what keeps the operator's move-the-file-away
|
||||||
// workflow working; the next write reopens (and recreates) the
|
// workflow working; the next write reopens (and recreates) the
|
||||||
// file as it always has.
|
// file as it always has.
|
||||||
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
||||||
|
w.mu.Lock()
|
||||||
|
files, err := archiveFiles(w.path)
|
||||||
|
w.mu.Unlock()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
err = w.sweepFile(file, expiry)
|
||||||
|
if errors.Is(err, errArchiveWriterEvicted) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sweepFile prunes one of the target's archive files for sweepExpired,
|
||||||
|
// holding w.mu while it does. It skips a file that is gone, and deletes
|
||||||
|
// the file, with its -wal and -shm, when it is named for a period and
|
||||||
|
// the prune leaves it empty.
|
||||||
|
func (w *archiveWriter) sweepFile(
|
||||||
|
file archiveFile, expiry time.Duration,
|
||||||
|
) error {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
|
|
||||||
@@ -385,7 +437,7 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !fileExists(w.path) {
|
if !fileExists(file.path) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,26 +445,56 @@ func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
|
|||||||
// freshly opened file, matching the write path's semantics.
|
// freshly opened file, matching the write path's semantics.
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
err := w.openMode(archiveModeExisting, expiry)
|
err := w.openMode(file.path, archiveModeExisting, expiry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if file.period == "" {
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows int64
|
||||||
|
|
||||||
|
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
|
||||||
|
|
||||||
|
w.close()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"counting rows in archive %s: %w", file.path, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
err = os.Remove(file.path + suffix)
|
||||||
|
if err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return fmt.Errorf("deleting empty archive file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.log.Info("deleted empty archive file", "path", file.path)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rename gives the archive file a new name in the same directory,
|
// rename gives every one of the target's archive files the new
|
||||||
// and the writer uses the file under that name from now on. The
|
// name, keeping the period in the name of each (see
|
||||||
// handle is closed first, which folds the -wal into the .db; any
|
// archivePeriodPath), and the writer uses the files under that name
|
||||||
// -wal or -shm still beside the file (left by a crash) is moved with
|
// from now on. The handle is closed first, which folds the -wal into
|
||||||
// it, because SQLite finds them by name. A missing file is not an
|
// the .db; any -wal or -shm still beside a file (left by a crash) is
|
||||||
// error: the operator may have moved it away, and the next write
|
// moved with it, because SQLite finds them by name. A target with no
|
||||||
// creates it under the new name.
|
// files is not an error: the operator may have moved them away, and
|
||||||
|
// the next write creates its file under the new name.
|
||||||
//
|
//
|
||||||
// If a file already has the new name, nothing is moved and the
|
// If a file already has one of the new names, nothing is moved and
|
||||||
// error is ErrArchiveNameTaken. If one file fails to move, those
|
// the error is ErrArchiveNameTaken. If one file fails to move, those
|
||||||
// already moved are moved back before the error is returned, so the
|
// already moved are moved back before the error is returned, so the
|
||||||
// archive is never split across two names.
|
// archive is never split across two names.
|
||||||
func (w *archiveWriter) rename(name string) error {
|
func (w *archiveWriter) rename(name string) error {
|
||||||
@@ -430,38 +512,53 @@ func (w *archiveWriter) rename(name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
suffixes := []string{"", "-wal", "-shm"}
|
files, err := archiveFiles(w.path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
for _, suffix := range suffixes {
|
// from[i] moves to to[i].
|
||||||
if fileExists(path + suffix) {
|
var from, to []string
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
renamed := archivePeriodPath(path, file.period)
|
||||||
|
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
from = append(from, file.path+suffix)
|
||||||
|
to = append(to, renamed+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, taken := range to {
|
||||||
|
if fileExists(taken) {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"%w: %s", ErrArchiveNameTaken, name+suffix,
|
"%w: %s", ErrArchiveNameTaken, filepath.Base(taken),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
w.close()
|
w.close()
|
||||||
|
|
||||||
for i, suffix := range suffixes {
|
for i := range from {
|
||||||
err := os.Rename(w.path+suffix, path+suffix)
|
err = os.Rename(from[i], to[i])
|
||||||
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
if err == nil || errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, moved := range suffixes[:i] {
|
for j := range i {
|
||||||
backErr := os.Rename(path+moved, w.path+moved)
|
backErr := os.Rename(to[j], from[j])
|
||||||
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
|
||||||
w.log.Error(
|
w.log.Error(
|
||||||
"failed to move archive file back",
|
"failed to move archive file back",
|
||||||
"from", path+moved,
|
"from", to[j],
|
||||||
"to", w.path+moved,
|
"to", from[j],
|
||||||
"error", backErr,
|
"error", backErr,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"renaming archive %s to %s: %w", w.path+suffix, path+suffix, err,
|
"renaming archive %s to %s: %w", from[i], to[i], err,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -499,7 +596,7 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
if res.Error != nil {
|
if res.Error != nil {
|
||||||
w.log.Error(
|
w.log.Error(
|
||||||
"failed to prune expired archive rows",
|
"failed to prune expired archive rows",
|
||||||
"path", w.path,
|
"path", w.current,
|
||||||
"error", res.Error,
|
"error", res.Error,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -509,12 +606,81 @@ func (w *archiveWriter) prune(expiry time.Duration) {
|
|||||||
if res.RowsAffected > 0 {
|
if res.RowsAffected > 0 {
|
||||||
w.log.Info(
|
w.log.Info(
|
||||||
"pruned expired archive rows",
|
"pruned expired archive rows",
|
||||||
"path", w.path,
|
"path", w.current,
|
||||||
"rows_deleted", res.RowsAffected,
|
"rows_deleted", res.RowsAffected,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ArchiveFileInfo is what the metadata of a database target's archive
|
||||||
|
// files says about them.
|
||||||
|
type ArchiveFileInfo struct {
|
||||||
|
// Files counts the files.
|
||||||
|
Files int
|
||||||
|
|
||||||
|
// Size is the bytes on disk of the files and their -wal together.
|
||||||
|
Size int64
|
||||||
|
|
||||||
|
// Written is when a file or a -wal was last modified, whichever is
|
||||||
|
// latest: a write lands in the -wal first.
|
||||||
|
Written time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatArchive reads the metadata of a database target's archive
|
||||||
|
// files, given the path ArchivePath gives it (see archiveFiles), and
|
||||||
|
// of their -wal, without opening them. With no files, which is so
|
||||||
|
// before the first write and after the operator moved them away, the
|
||||||
|
// error wraps fs.ErrNotExist.
|
||||||
|
func StatArchive(path string) (ArchiveFileInfo, error) {
|
||||||
|
files, err := archiveFiles(path)
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var info ArchiveFileInfo
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
db, err := os.Stat(file.path)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Files++
|
||||||
|
info.Size += db.Size()
|
||||||
|
|
||||||
|
if db.ModTime().After(info.Written) {
|
||||||
|
info.Written = db.ModTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
wal, err := os.Stat(file.path + "-wal")
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return ArchiveFileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Size += wal.Size()
|
||||||
|
|
||||||
|
if wal.ModTime().After(info.Written) {
|
||||||
|
info.Written = wal.ModTime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Files == 0 {
|
||||||
|
return ArchiveFileInfo{}, fmt.Errorf(
|
||||||
|
"no archive file for %s: %w", path, fs.ErrNotExist,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
|
||||||
// fileExists reports whether a path currently exists.
|
// fileExists reports whether a path currently exists.
|
||||||
func fileExists(path string) bool {
|
func fileExists(path string) bool {
|
||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveTableQuery counts the archive's table: 0 when the archive
|
||||||
|
// writer has created the file but not yet the table in it.
|
||||||
|
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
|
||||||
|
"WHERE type = 'table' AND name = 'archived_events'"
|
||||||
|
|
||||||
|
// ArchivePath returns where a database target's archive file is: in
|
||||||
|
// the data directory, beside the webhook's event database, under the
|
||||||
|
// name ArchiveFileName gives it.
|
||||||
|
func ArchivePath(
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
target *database.Target,
|
||||||
|
) string {
|
||||||
|
return filepath.Join(
|
||||||
|
filepath.Dir(dbMgr.DBPath(webhook.ID)),
|
||||||
|
ArchiveFileName(webhook.Name, target.Name, target.ID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveExportFileName returns the name a database target's archive
|
||||||
|
// downloads under:
|
||||||
|
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
|
||||||
|
// made safe as in ArchiveFileName and the time in UTC.
|
||||||
|
func ArchiveExportFileName(
|
||||||
|
webhookName, targetName string, at time.Time,
|
||||||
|
) string {
|
||||||
|
return "archive-" + archiveNamePart(webhookName) + "-" +
|
||||||
|
archiveNamePart(targetName) + "-" +
|
||||||
|
at.UTC().Format("20060102T150405Z") + ".json.gz"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchiveExport is a database target's archive listed for download. It
|
||||||
|
// opens one of the target's files at a time, only when its rows are
|
||||||
|
// about to be written out, and closes it before it opens the next, so
|
||||||
|
// an export holds at most one file open however many the target has.
|
||||||
|
//
|
||||||
|
// Each file is read on its own connection inside one read-only
|
||||||
|
// transaction, so its rows are written out as the file stood when it
|
||||||
|
// was opened. Archives are in WAL mode, where a reader works from a
|
||||||
|
// snapshot and never blocks a writer: archive writes go on while a file
|
||||||
|
// is open, and the export does not see them. SQLite cannot checkpoint
|
||||||
|
// a -wal past an open snapshot, so the open file's -wal grows until the
|
||||||
|
// export has written that file out.
|
||||||
|
type ArchiveExport struct {
|
||||||
|
// periods are the periods of the target's files when the export
|
||||||
|
// was listed, "" for the file without one, in the order
|
||||||
|
// archiveFiles lists them.
|
||||||
|
periods []string
|
||||||
|
|
||||||
|
// lock is held while currentPath is called and a file is opened,
|
||||||
|
// so that a rename, which holds it too, cannot move the file in
|
||||||
|
// between.
|
||||||
|
lock sync.Locker
|
||||||
|
|
||||||
|
// currentPath returns the path ArchivePath gives the target under
|
||||||
|
// the names stored for it now, which a rename may have changed since
|
||||||
|
// the export was listed.
|
||||||
|
currentPath func() (string, error)
|
||||||
|
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportFile is one archive file opened for an export.
|
||||||
|
type exportFile struct {
|
||||||
|
db *sql.DB
|
||||||
|
tx *gorm.DB
|
||||||
|
|
||||||
|
// period is the period in the file's name, "" for none.
|
||||||
|
period string
|
||||||
|
|
||||||
|
// empty is true for a file without the archive's table yet.
|
||||||
|
empty bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportedName is how an export names its webhook and its target.
|
||||||
|
type exportedName struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewArchiveExport lists a database target's archive files for export,
|
||||||
|
// given the path ArchivePath gives it (see archiveFiles). It opens none
|
||||||
|
// of them. Its caller holds lock, which every rename of the target's
|
||||||
|
// files runs under, from reading the names path is made of until it
|
||||||
|
// returns, so the files it lists are the ones those names give.
|
||||||
|
//
|
||||||
|
// WriteGzipJSON, called without lock held, finds each file again by its
|
||||||
|
// period under the path currentPath gives, holding lock while it does
|
||||||
|
// and while it opens the file, so a rename during the export loses no
|
||||||
|
// file. A file that is gone by then, emptied by the sweep or moved
|
||||||
|
// away, is skipped. The export never creates a file: with no files, it
|
||||||
|
// has no rows.
|
||||||
|
func NewArchiveExport(
|
||||||
|
path string,
|
||||||
|
lock sync.Locker,
|
||||||
|
currentPath func() (string, error),
|
||||||
|
log *slog.Logger,
|
||||||
|
) (*ArchiveExport, error) {
|
||||||
|
files, err := archiveFiles(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
x := &ArchiveExport{lock: lock, currentPath: currentPath, log: log}
|
||||||
|
|
||||||
|
for _, file := range files {
|
||||||
|
x.periods = append(x.periods, file.period)
|
||||||
|
}
|
||||||
|
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openExportFile opens one archive file for an export and takes its
|
||||||
|
// snapshot. The transaction lasts as long as ctx does.
|
||||||
|
func openExportFile(
|
||||||
|
ctx context.Context, file archiveFile, log *slog.Logger,
|
||||||
|
) (*exportFile, error) {
|
||||||
|
db, err := database.OpenSQLite(file.path, archiveModeExisting)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gdb, err := gorm.Open(
|
||||||
|
sqlite.Dialector{Conn: db}, &gorm.Config{
|
||||||
|
// Never leave this at GORM's default. See
|
||||||
|
// internal/gormlog.
|
||||||
|
Logger: gormlog.New(log),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadOnly makes the driver begin a deferred transaction in place
|
||||||
|
// of the BEGIN IMMEDIATE the connection string asks for, so the
|
||||||
|
// export never takes the archive's write lock.
|
||||||
|
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
|
||||||
|
if tx.Error != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf(
|
||||||
|
"reading archive %s: %w", file.path, tx.Error,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The transaction's first read is what takes the snapshot.
|
||||||
|
var tables int
|
||||||
|
|
||||||
|
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
|
||||||
|
if err != nil {
|
||||||
|
_ = tx.Rollback()
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &exportFile{
|
||||||
|
db: db, tx: tx, period: file.period, empty: tables == 0,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteGzipJSON writes the export to w as one gzipped JSON object:
|
||||||
|
// webhook and target, each an id and a name; exported_at; and
|
||||||
|
// archived_events, one object per archived row, keyed by column name,
|
||||||
|
// the files in the order archiveFiles lists them. A row from a file
|
||||||
|
// named for a period has "period" beside its columns. A body that is
|
||||||
|
// not valid UTF-8 cannot be a JSON string, so it is written in base64,
|
||||||
|
// with "body_encoding": "base64" beside it.
|
||||||
|
//
|
||||||
|
// Each row is written out before the next is read, so neither the
|
||||||
|
// archive nor its JSON is ever held in memory whole, and each file is
|
||||||
|
// closed once its rows are written, before the next is opened. When it
|
||||||
|
// returns, no file is open. After an error the gzip stream is left
|
||||||
|
// unfinished, so what was written does not decompress as a whole file.
|
||||||
|
func (x *ArchiveExport) WriteGzipJSON(
|
||||||
|
ctx context.Context,
|
||||||
|
w io.Writer,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
target *database.Target,
|
||||||
|
exportedAt time.Time,
|
||||||
|
) error {
|
||||||
|
head, err := json.Marshal(map[string]any{
|
||||||
|
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
|
||||||
|
"target": exportedName{ID: target.ID, Name: target.Name},
|
||||||
|
"exported_at": exportedAt.UTC(),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("encoding archive export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
zw := gzip.NewWriter(w)
|
||||||
|
|
||||||
|
err = x.writeJSON(ctx, zw, head)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing archive export: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return zw.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFile finds the target's archive file for period under the path
|
||||||
|
// currentPath gives now, and opens it for the export, holding x.lock
|
||||||
|
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
|
||||||
|
func (x *ArchiveExport) openFile(
|
||||||
|
ctx context.Context, period string,
|
||||||
|
) (*exportFile, error) {
|
||||||
|
x.lock.Lock()
|
||||||
|
defer x.lock.Unlock()
|
||||||
|
|
||||||
|
path, err := x.currentPath()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("finding archive file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
file := archiveFile{path: archivePeriodPath(path, period), period: period}
|
||||||
|
|
||||||
|
_, err = os.Stat(file.path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return openExportFile(ctx, file, x.log)
|
||||||
|
}
|
||||||
|
|
||||||
|
// close ends the file's transaction and closes its connection.
|
||||||
|
func (f *exportFile) close() error {
|
||||||
|
_ = f.tx.Rollback()
|
||||||
|
|
||||||
|
return f.db.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes head with archived_events added as its last key,
|
||||||
|
// the rows going into it one at a time.
|
||||||
|
func (x *ArchiveExport) writeJSON(
|
||||||
|
ctx context.Context, w io.Writer, head []byte,
|
||||||
|
) error {
|
||||||
|
// head goes out without its closing brace, so that
|
||||||
|
// archived_events can follow it.
|
||||||
|
_, err := w.Write(head[:len(head)-1])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(w, `,"archived_events":[`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = x.writeRows(ctx, w)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(w, "\n]}\n")
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRows writes the archived rows of each file to w, one per line,
|
||||||
|
// separated by commas, opening each file in turn and closing it once
|
||||||
|
// its rows are written.
|
||||||
|
func (x *ArchiveExport) writeRows(ctx context.Context, w io.Writer) error {
|
||||||
|
sep := "\n"
|
||||||
|
|
||||||
|
for _, period := range x.periods {
|
||||||
|
f, err := x.openFile(ctx, period)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
sep, err = f.writeRows(ctx, w, sep)
|
||||||
|
|
||||||
|
err = errors.Join(err, f.close())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRows writes the file's archived rows to w, oldest first, the
|
||||||
|
// first after sep and each other after ",\n". It returns what goes
|
||||||
|
// before the next row: sep again when the file had no rows.
|
||||||
|
func (f *exportFile) writeRows(
|
||||||
|
ctx context.Context, w io.Writer, sep string,
|
||||||
|
) (string, error) {
|
||||||
|
if f.empty {
|
||||||
|
return sep, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := f.tx.WithContext(ctx).
|
||||||
|
Model(&archivedEvent{}).Order("id").Rows()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
for ; rows.Next(); sep = ",\n" {
|
||||||
|
var ev archivedEvent
|
||||||
|
|
||||||
|
err = f.tx.ScanRows(rows, &ev)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.WriteString(w, sep)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = writeRow(w, &ev, f.period)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sep, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeRow writes an archived row to w as a JSON object keyed by
|
||||||
|
// column name, its body in base64 when it is not valid UTF-8, with
|
||||||
|
// the period of its file beside them unless that is "".
|
||||||
|
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
|
||||||
|
row := map[string]any{
|
||||||
|
"id": ev.ID,
|
||||||
|
"event_id": ev.EventID,
|
||||||
|
"webhook_id": ev.WebhookID,
|
||||||
|
"entrypoint_id": ev.EntrypointID,
|
||||||
|
"method": ev.Method,
|
||||||
|
"raw_query": ev.RawQuery,
|
||||||
|
"headers": ev.Headers,
|
||||||
|
"body": ev.Body,
|
||||||
|
"content_type": ev.ContentType,
|
||||||
|
"archived_at": ev.ArchivedAt.UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utf8.ValidString(ev.Body) {
|
||||||
|
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
|
||||||
|
row["body_encoding"] = "base64"
|
||||||
|
}
|
||||||
|
|
||||||
|
if period != "" {
|
||||||
|
row["period"] = period
|
||||||
|
}
|
||||||
|
|
||||||
|
line, err := json.Marshal(row)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = w.Write(line)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The webhook and the target the export tests' archives belong to.
|
||||||
|
const (
|
||||||
|
exportWebhookID = "wh-export"
|
||||||
|
exportWebhookName = "Orders (EU)"
|
||||||
|
exportTargetID = "tgt-export"
|
||||||
|
exportTargetName = "Long-term archive"
|
||||||
|
)
|
||||||
|
|
||||||
|
// binaryBody is a body that is not valid UTF-8.
|
||||||
|
const binaryBody = "\xff\xfe\x00\x01binary\x80"
|
||||||
|
|
||||||
|
// writeExportTo writes export to w as the archive of the export tests'
|
||||||
|
// webhook and target, exported at 2026-10-02T12:03:04Z.
|
||||||
|
func writeExportTo(
|
||||||
|
t *testing.T, export *delivery.ArchiveExport, w io.Writer,
|
||||||
|
) error {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return export.WriteGzipJSON(
|
||||||
|
t.Context(), w,
|
||||||
|
&database.Webhook{
|
||||||
|
BaseModel: database.BaseModel{ID: exportWebhookID},
|
||||||
|
Name: exportWebhookName,
|
||||||
|
},
|
||||||
|
&database.Target{
|
||||||
|
BaseModel: database.BaseModel{ID: exportTargetID},
|
||||||
|
Name: exportTargetName,
|
||||||
|
},
|
||||||
|
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listExport lists the archive at path for export, as the archive of a
|
||||||
|
// target whose names do not change.
|
||||||
|
func listExport(t *testing.T, path string) *delivery.ArchiveExport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
||||||
|
return path, nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// newExport lists the archive at path for export, to find each file
|
||||||
|
// again under the path currentPath gives, holding lock while it does.
|
||||||
|
// Nothing else takes lock while it lists, so it does not hold lock.
|
||||||
|
func newExport(
|
||||||
|
t *testing.T,
|
||||||
|
path string,
|
||||||
|
lock sync.Locker,
|
||||||
|
currentPath func() (string, error),
|
||||||
|
) *delivery.ArchiveExport {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
export, err := delivery.NewArchiveExport(
|
||||||
|
path, lock, currentPath, archiveTestLogger(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return export
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportArchive runs a whole export of the archive at path and returns
|
||||||
|
// its JSON, decompressed and parsed.
|
||||||
|
func exportArchive(t *testing.T, path string) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return writeExport(t, listExport(t, path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeExport writes an opened export and returns its JSON,
|
||||||
|
// decompressed and parsed. Reading to the end makes the gzip reader
|
||||||
|
// check that the stream was finished.
|
||||||
|
func writeExport(
|
||||||
|
t *testing.T, export *delivery.ArchiveExport,
|
||||||
|
) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
require.NoError(t, writeExportTo(t, export, &buf))
|
||||||
|
|
||||||
|
zr, err := gzip.NewReader(&buf)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
raw, err := io.ReadAll(zr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got map[string]any
|
||||||
|
|
||||||
|
require.NoError(t, json.Unmarshal(raw, &got))
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportedEvents returns an export's archived_events.
|
||||||
|
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
list, ok := got["archived_events"].([]any)
|
||||||
|
require.True(t, ok, "archived_events must be an array: %v", got)
|
||||||
|
|
||||||
|
events := make([]map[string]any, len(list))
|
||||||
|
|
||||||
|
for i, v := range list {
|
||||||
|
events[i], ok = v.(map[string]any)
|
||||||
|
require.True(t, ok, "an archived event must be an object: %v", v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportedEventIDs returns the event_id of each of an export's
|
||||||
|
// archived_events.
|
||||||
|
func exportedEventIDs(t *testing.T, got map[string]any) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
events := exportedEvents(t, got)
|
||||||
|
ids := make([]string, 0, len(events))
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_MatchesStoredRows proves an export holds the
|
||||||
|
// webhook, the target, the time, and every column of every stored
|
||||||
|
// row: a body that is valid UTF-8 as a string, and one that is not in
|
||||||
|
// base64, marked as such.
|
||||||
|
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
|
||||||
|
|
||||||
|
for i, body := range bodies {
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
||||||
|
EventID: fmt.Sprintf("ev-%d", i),
|
||||||
|
WebhookID: exportWebhookID,
|
||||||
|
EntrypointID: "ep-1",
|
||||||
|
Method: "POST",
|
||||||
|
RawQuery: eventQuery,
|
||||||
|
Headers: `{"X-Test":["yes"]}`,
|
||||||
|
Body: body,
|
||||||
|
ContentType: testContentType,
|
||||||
|
}, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
var stored []delivery.ExportArchivedEvent
|
||||||
|
|
||||||
|
require.NoError(t, openArchiveDBForRead(t, path).
|
||||||
|
Order("id").Find(&stored).Error)
|
||||||
|
|
||||||
|
got := exportArchive(t, path)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
|
||||||
|
got["webhook"],
|
||||||
|
)
|
||||||
|
assert.Equal(t,
|
||||||
|
map[string]any{"id": exportTargetID, "name": exportTargetName},
|
||||||
|
got["target"],
|
||||||
|
)
|
||||||
|
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
|
||||||
|
|
||||||
|
events := exportedEvents(t, got)
|
||||||
|
require.Len(t, events, len(bodies))
|
||||||
|
|
||||||
|
for i, row := range stored {
|
||||||
|
assertExportedRow(t, row, events[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// assertExportedRow checks that ev, from an export, holds every column
|
||||||
|
// of the stored row.
|
||||||
|
func assertExportedRow(
|
||||||
|
t *testing.T, row delivery.ExportArchivedEvent, ev map[string]any,
|
||||||
|
) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
archivedAt, err := time.Parse(
|
||||||
|
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, archivedAt.Equal(row.ArchivedAt))
|
||||||
|
|
||||||
|
assert.EqualValues(t, row.ID, ev["id"])
|
||||||
|
assert.Equal(t, row.EventID, ev["event_id"])
|
||||||
|
assert.Equal(t, row.WebhookID, ev["webhook_id"])
|
||||||
|
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
|
||||||
|
assert.Equal(t, row.Method, ev["method"])
|
||||||
|
assert.Equal(t, row.RawQuery, ev["raw_query"])
|
||||||
|
assert.Equal(t, row.Headers, ev["headers"])
|
||||||
|
assert.Equal(t, row.ContentType, ev["content_type"])
|
||||||
|
|
||||||
|
if row.Body != binaryBody {
|
||||||
|
assert.Equal(t, row.Body, ev["body"])
|
||||||
|
assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, binaryBody, string(body))
|
||||||
|
assert.Equal(t, "base64", ev["body_encoding"])
|
||||||
|
assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_Empty proves an archive with nothing in it exports
|
||||||
|
// as an empty archived_events: no file, which the export must not
|
||||||
|
// create; a file the archive writer has not yet put its table in; and
|
||||||
|
// a table with no rows.
|
||||||
|
func TestArchiveExport_Empty(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
missing := filepath.Join(dir, "missing.db")
|
||||||
|
noTable := filepath.Join(dir, "no-table.db")
|
||||||
|
noRows := filepath.Join(dir, "no-rows.db")
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
|
||||||
|
require.NoError(t,
|
||||||
|
delivery.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
|
||||||
|
Open(0),
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, path := range []string{missing, noTable, noRows} {
|
||||||
|
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
assert.NoFileExists(t, missing+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
|
||||||
|
// export unlocks its lock right after it opens a file.
|
||||||
|
type unlockHook struct {
|
||||||
|
sync.Mutex
|
||||||
|
|
||||||
|
fn func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *unlockHook) Unlock() {
|
||||||
|
u.Mutex.Unlock()
|
||||||
|
u.fn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_ReadsOneSnapshot proves an export writes a file
|
||||||
|
// out as it was when the export opened it, and holds up no archive
|
||||||
|
// write: a row written after the export was listed but before the file
|
||||||
|
// was opened is in the export, and one written while the file is open
|
||||||
|
// is stored, and is not. A write held up for the whole busy timeout
|
||||||
|
// would fail.
|
||||||
|
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
|
||||||
|
|
||||||
|
opened := &unlockHook{fn: func() {
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
|
||||||
|
}}
|
||||||
|
export := newExport(t, path, opened, func() (string, error) {
|
||||||
|
return path, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "before-open"}, 0))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"listed", "before-open"},
|
||||||
|
exportedEventIDs(t, writeExport(t, export)),
|
||||||
|
)
|
||||||
|
|
||||||
|
var stored int64
|
||||||
|
|
||||||
|
require.NoError(t, openArchiveDBForRead(t, path).
|
||||||
|
Model(&delivery.ExportArchivedEvent{}).Count(&stored).Error)
|
||||||
|
assert.Equal(t, int64(3), stored)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
|
||||||
|
// archive after an export has listed it, as renaming its webhook or
|
||||||
|
// target does, loses no file: the export finds each file again by its
|
||||||
|
// period under the new name. A file moved away by then is skipped.
|
||||||
|
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "archive-old.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
for _, period := range []string{"", dayPeriod, hourPeriod} {
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
current := path
|
||||||
|
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
|
||||||
|
return current, nil
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, w.Rename("archive-new.db"))
|
||||||
|
|
||||||
|
current = filepath.Join(dir, "archive-new.db")
|
||||||
|
|
||||||
|
removeArchiveFiles(t, periodPath(current, dayPeriod))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"in-", "in-" + hourPeriod},
|
||||||
|
exportedEventIDs(t, writeExport(t, export)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_EveryFileOldestFirst writes a row to a target's
|
||||||
|
// file without a period and to its files for a month, an hour and a
|
||||||
|
// day, and proves the export holds every row: the file without a
|
||||||
|
// period first, then the others oldest period first, each row from a
|
||||||
|
// file named for a period carrying that period. A file made after the
|
||||||
|
// export was listed is not in it.
|
||||||
|
func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive-wh.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
// Written in an order that is not the export's.
|
||||||
|
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
export := listExport(t, path)
|
||||||
|
|
||||||
|
require.NoError(t, w.WritePeriod(
|
||||||
|
delivery.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
|
||||||
|
))
|
||||||
|
|
||||||
|
events := exportedEvents(t, writeExport(t, export))
|
||||||
|
ids := make([]string, 0, len(events))
|
||||||
|
periods := make([]any, 0, len(events))
|
||||||
|
|
||||||
|
for _, ev := range events {
|
||||||
|
ids = append(ids, fmt.Sprint(ev["event_id"]))
|
||||||
|
periods = append(periods, ev["period"])
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod},
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
assert.Equal(t,
|
||||||
|
[]any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods,
|
||||||
|
)
|
||||||
|
assert.NotContains(t, events[0], "period",
|
||||||
|
"a row from the file without a period has no period")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openFilesPeak is an io.Writer that discards what it is given and
|
||||||
|
// records the most archive files in dir the process had open at any
|
||||||
|
// write, as /proc/self/fd lists the files a process has open.
|
||||||
|
type openFilesPeak struct {
|
||||||
|
dir string
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *openFilesPeak) Write(b []byte) (int, error) {
|
||||||
|
fds, err := os.ReadDir("/proc/self/fd")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
open := map[string]bool{}
|
||||||
|
|
||||||
|
for _, fd := range fds {
|
||||||
|
file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
|
||||||
|
if err == nil && filepath.Dir(file) == p.dir &&
|
||||||
|
strings.HasSuffix(file, ".db") {
|
||||||
|
open[file] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.max = max(p.max, len(open))
|
||||||
|
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_OneFileOpenAtATime exports a target with a file for
|
||||||
|
// each of 24 hours and proves the export never had more than one of
|
||||||
|
// them open, and had one open while it wrote. Each file holds a row of
|
||||||
|
// 48 KiB of random base64, which gzip shrinks little, so the export
|
||||||
|
// writes output while it reads each file.
|
||||||
|
func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
if runtime.GOOS != "linux" {
|
||||||
|
t.Skip("only Linux lists a process's open files in /proc/self/fd")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Readlink gives each open file's path with no symbolic link in it.
|
||||||
|
dir, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
path := filepath.Join(dir, "archive-wh.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
random := make([]byte, 36<<10)
|
||||||
|
|
||||||
|
for hour := range 24 {
|
||||||
|
_, _ = rand.Read(random)
|
||||||
|
|
||||||
|
require.NoError(t, w.WritePeriod(delivery.ExportArchivedEvent{
|
||||||
|
Body: base64.StdEncoding.EncodeToString(random),
|
||||||
|
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The writer's own handle on the last file is not the export's.
|
||||||
|
w.Evict()
|
||||||
|
|
||||||
|
// Through a buffer, the open files are listed once per 8 KiB of
|
||||||
|
// output, a few times for each file, rather than at each of gzip's
|
||||||
|
// small writes, which takes far longer.
|
||||||
|
peak := &openFilesPeak{dir: dir}
|
||||||
|
buffered := bufio.NewWriterSize(peak, 8<<10)
|
||||||
|
|
||||||
|
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
|
||||||
|
require.NoError(t, buffered.Flush())
|
||||||
|
assert.Equal(t, 1, peak.max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// heapPeak is an io.Writer that discards what it is given and records
|
||||||
|
// the largest heap it saw at a write. It collects garbage twice before
|
||||||
|
// each reading, so the heap it reads is what is still held. Once is not
|
||||||
|
// enough: the libraries the export calls (regexp, under GORM's table
|
||||||
|
// names, and encoding/json among them) cache buffers in a sync.Pool,
|
||||||
|
// which keeps them through one collection, so after one the reading
|
||||||
|
// counts however many happen to be cached. That varies from run to run
|
||||||
|
// by about as much as the limit in TestArchiveExport_Streams.
|
||||||
|
type heapPeak struct {
|
||||||
|
max uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *heapPeak) Write(b []byte) (int, error) {
|
||||||
|
var m runtime.MemStats
|
||||||
|
|
||||||
|
runtime.GC()
|
||||||
|
runtime.GC()
|
||||||
|
runtime.ReadMemStats(&m)
|
||||||
|
p.max = max(p.max, m.HeapAlloc)
|
||||||
|
|
||||||
|
return len(b), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exportHeapGrowth exports an archive of rows random bodies, each
|
||||||
|
// bodySize bytes of base64, and returns how far the heap rose above
|
||||||
|
// where it stood when the export began, at its highest.
|
||||||
|
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
path := filepath.Join(t.TempDir(), "archive.db")
|
||||||
|
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
|
||||||
|
|
||||||
|
// Base64 makes four characters of every three bytes.
|
||||||
|
random := make([]byte, bodySize/4*3)
|
||||||
|
|
||||||
|
for range rows {
|
||||||
|
_, _ = rand.Read(random)
|
||||||
|
|
||||||
|
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
|
||||||
|
Body: base64.StdEncoding.EncodeToString(random),
|
||||||
|
}, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
export := listExport(t, path)
|
||||||
|
|
||||||
|
// Twice, for the reason heapPeak gives.
|
||||||
|
runtime.GC()
|
||||||
|
runtime.GC()
|
||||||
|
|
||||||
|
var start runtime.MemStats
|
||||||
|
|
||||||
|
runtime.ReadMemStats(&start)
|
||||||
|
|
||||||
|
// Through a buffer, the heap is read once per 8 KiB of output
|
||||||
|
// rather than at each of gzip's small writes, which takes far
|
||||||
|
// longer.
|
||||||
|
peak := &heapPeak{max: start.HeapAlloc}
|
||||||
|
buffered := bufio.NewWriterSize(peak, 8<<10)
|
||||||
|
|
||||||
|
require.NoError(t, writeExportTo(t, export, buffered))
|
||||||
|
require.NoError(t, buffered.Flush())
|
||||||
|
|
||||||
|
return peak.max - start.HeapAlloc
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExport_Streams proves an export holds neither the archive
|
||||||
|
// nor its output in memory whole: exporting 384 KiB more of archive
|
||||||
|
// raises the heap's peak by less than half of that. The export's own
|
||||||
|
// memory, mostly gzip's compressor, is the same for both archives, so
|
||||||
|
// it cancels out. The bodies are random bytes in base64, which gzip
|
||||||
|
// shrinks by only a quarter, so an export that read every row before
|
||||||
|
// writing, or built the JSON or the gzipped file before writing it,
|
||||||
|
// would raise the peak by at least three quarters of the difference.
|
||||||
|
//
|
||||||
|
// The smaller archive has two rows so that its export, too, writes
|
||||||
|
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
|
||||||
|
// the heap must be read while the export's own memory is held.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // It measures the heap, which tests share.
|
||||||
|
func TestArchiveExport_Streams(t *testing.T) {
|
||||||
|
const (
|
||||||
|
bodySize = 16 << 10
|
||||||
|
smallRows = 2
|
||||||
|
largeRows = smallRows + 24
|
||||||
|
limit = (largeRows - smallRows) * bodySize / 2
|
||||||
|
)
|
||||||
|
|
||||||
|
small := exportHeapGrowth(t, smallRows, bodySize)
|
||||||
|
large := exportHeapGrowth(t, largeRows, bodySize)
|
||||||
|
|
||||||
|
assert.Less(t, large, small+limit,
|
||||||
|
"the heap rose by %d for %d rows and by %d for %d rows",
|
||||||
|
small, smallRows, large, largeRows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExportFileName proves the download is named for the
|
||||||
|
// webhook and the target, with the names made safe as for the archive
|
||||||
|
// file, and the export time in UTC.
|
||||||
|
func TestArchiveExportFileName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
|
||||||
|
delivery.ArchiveExportFileName(
|
||||||
|
exportWebhookName, exportTargetName,
|
||||||
|
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package delivery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The archive rotations: how often a database target starts a new
|
||||||
|
// archive file. Every rotation but none puts the period of an event's
|
||||||
|
// receive time, in UTC, in the name of the file the event goes to,
|
||||||
|
// written in the layout beside it.
|
||||||
|
const (
|
||||||
|
archiveRotationNone = "none"
|
||||||
|
archiveRotationMonthly = "monthly"
|
||||||
|
archiveRotationDaily = "daily"
|
||||||
|
archiveRotationHourly = "hourly"
|
||||||
|
|
||||||
|
archiveMonthLayout = "2006-01"
|
||||||
|
archiveDayLayout = "2006-01-02"
|
||||||
|
archiveHourLayout = "2006-01-02-15"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errArchiveRotationUnknown is returned for a rotation that is not one
|
||||||
|
// of the four.
|
||||||
|
var errArchiveRotationUnknown = errors.New(
|
||||||
|
"rotation must be none, monthly, daily or hourly",
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveFile is one of a database target's archive files, and the
|
||||||
|
// period in its name: "" for the file named without one.
|
||||||
|
type archiveFile struct {
|
||||||
|
path string
|
||||||
|
period string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateArchiveRotation checks a user-supplied archive rotation for
|
||||||
|
// a database target: empty or none (both meaning one file), monthly,
|
||||||
|
// daily or hourly.
|
||||||
|
func ValidateArchiveRotation(rotation string) error {
|
||||||
|
switch rotation {
|
||||||
|
case "", archiveRotationNone, archiveRotationMonthly,
|
||||||
|
archiveRotationDaily, archiveRotationHourly:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseArchiveRotation reads the rotation from a database target's
|
||||||
|
// config JSON. An empty config or an empty rotation is none.
|
||||||
|
func parseArchiveRotation(configJSON string) (string, error) {
|
||||||
|
if configJSON == "" {
|
||||||
|
return archiveRotationNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg databaseTargetConfig
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(configJSON), &cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parsing database target config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateArchiveRotation(cfg.Rotation)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Rotation == "" {
|
||||||
|
return archiveRotationNone, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg.Rotation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archivePeriod returns the period, in UTC, that a rotation puts an
|
||||||
|
// event received at receivedAt in: "2026-10" for monthly,
|
||||||
|
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
|
||||||
|
func archivePeriod(rotation string, receivedAt time.Time) string {
|
||||||
|
switch rotation {
|
||||||
|
case archiveRotationMonthly:
|
||||||
|
return receivedAt.UTC().Format(archiveMonthLayout)
|
||||||
|
case archiveRotationDaily:
|
||||||
|
return receivedAt.UTC().Format(archiveDayLayout)
|
||||||
|
case archiveRotationHourly:
|
||||||
|
return receivedAt.UTC().Format(archiveHourLayout)
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archivePeriodPath returns the path of a database target's archive
|
||||||
|
// file for a period: path, as ArchivePath gives it, with "-" and the
|
||||||
|
// period put before its ".db". The period "" gives path itself.
|
||||||
|
func archivePeriodPath(path, period string) string {
|
||||||
|
if period == "" {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArchivePathAt returns the archive file a database target writes an
|
||||||
|
// event received at receivedAt to: ArchivePath's file, with the
|
||||||
|
// period in its name when the target rotates.
|
||||||
|
func ArchivePathAt(
|
||||||
|
dbMgr *database.WebhookDBManager,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
target *database.Target,
|
||||||
|
receivedAt time.Time,
|
||||||
|
) (string, error) {
|
||||||
|
rotation, err := parseArchiveRotation(target.Config)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return archivePeriodPath(
|
||||||
|
ArchivePath(dbMgr, webhook, target),
|
||||||
|
archivePeriod(rotation, receivedAt),
|
||||||
|
), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveFiles lists the database target's archive files that exist,
|
||||||
|
// given the path ArchivePath gives it: the file at path, then each
|
||||||
|
// file archivePeriodPath names for path and a period, oldest period
|
||||||
|
// first. Which rotation wrote a file does not matter, so the files of
|
||||||
|
// an earlier rotation setting are listed too.
|
||||||
|
func archiveFiles(path string) ([]archiveFile, error) {
|
||||||
|
dir := filepath.Dir(path)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing archive files: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stem := strings.TrimSuffix(filepath.Base(path), ".db")
|
||||||
|
|
||||||
|
var files []archiveFile
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
period, ok := archiveFilePeriod(stem, entry.Name())
|
||||||
|
if ok {
|
||||||
|
files = append(files, archiveFile{
|
||||||
|
path: filepath.Join(dir, entry.Name()),
|
||||||
|
period: period,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A month sorts before the days and hours in it.
|
||||||
|
slices.SortFunc(files, func(a, b archiveFile) int {
|
||||||
|
return strings.Compare(a.period, b.period)
|
||||||
|
})
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveFilePeriod reports whether name is the name of an archive
|
||||||
|
// file of the target whose file name without a period is stem+".db",
|
||||||
|
// and the period in it.
|
||||||
|
func archiveFilePeriod(stem, name string) (string, bool) {
|
||||||
|
rest, ok := strings.CutPrefix(name, stem)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
rest, ok = strings.CutSuffix(rest, ".db")
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if rest == "" {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
period, ok := strings.CutPrefix(rest, "-")
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, layout := range []string{
|
||||||
|
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
|
||||||
|
} {
|
||||||
|
_, err := time.Parse(layout, period)
|
||||||
|
if err == nil {
|
||||||
|
return period, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database/databasetest"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The archive rotations, and the configs of a daily target and of one
|
||||||
|
// whose rotation is not one of the four.
|
||||||
|
const (
|
||||||
|
rotationNone = "none"
|
||||||
|
rotationMonthly = "monthly"
|
||||||
|
rotationDaily = "daily"
|
||||||
|
rotationHourly = "hourly"
|
||||||
|
|
||||||
|
dailyConfig = `{"rotation":"daily"}`
|
||||||
|
weeklyConfig = `{"rotation":"weekly"}`
|
||||||
|
)
|
||||||
|
|
||||||
|
// The periods the tests archive into most: two days, and an hour of
|
||||||
|
// the first.
|
||||||
|
const (
|
||||||
|
dayPeriod = "2026-03-04"
|
||||||
|
nextDayPeriod = "2026-03-05"
|
||||||
|
hourPeriod = "2026-03-04-05"
|
||||||
|
)
|
||||||
|
|
||||||
|
// periodPath returns the archive file for a period of the target whose
|
||||||
|
// file without a period is path.
|
||||||
|
func periodPath(path, period string) string {
|
||||||
|
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverReceivedAt delivers to a database target an event whose
|
||||||
|
// receive time is receivedAt, and returns the event's id. The receive
|
||||||
|
// time is what decides a rotated archive's file, so setting it is how
|
||||||
|
// these tests move the clock across a period boundary.
|
||||||
|
func (env *archiveEnv) deliverReceivedAt(
|
||||||
|
t *testing.T, tgt *database.Target, receivedAt time.Time,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
webhookDB := testWebhookDB(t)
|
||||||
|
event := seedEvent(t, webhookDB, `{"n":1}`)
|
||||||
|
event.CreatedAt = receivedAt
|
||||||
|
|
||||||
|
env.eng.ExportDeliverDatabase(
|
||||||
|
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
|
||||||
|
)
|
||||||
|
|
||||||
|
return event.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, for each
|
||||||
|
// rotation, an event received in the last second of a period and one
|
||||||
|
// received in the first second of the next, and checks each lands in
|
||||||
|
// the file named for its own period, in UTC. Rotation none keeps both
|
||||||
|
// in the one file.
|
||||||
|
func TestDeliverDatabase_RotatesAtEachPeriodBoundary(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
berlin := time.FixedZone("CEST", 2*60*60)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rotation string
|
||||||
|
before, after time.Time
|
||||||
|
// periods are the periods of before and after.
|
||||||
|
periods [2]string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
rotationMonthly, rotationMonthly,
|
||||||
|
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
|
||||||
|
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||||
|
[2]string{"2026-01", "2026-02"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotationDaily, rotationDaily,
|
||||||
|
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
|
||||||
|
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
|
||||||
|
[2]string{dayPeriod, nextDayPeriod},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// The same instants, received in a zone two hours ahead
|
||||||
|
// of UTC, where they fall on 5 March: the period is UTC's.
|
||||||
|
"daily in another zone", rotationDaily,
|
||||||
|
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
|
||||||
|
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
|
||||||
|
[2]string{dayPeriod, nextDayPeriod},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rotationHourly, rotationHourly,
|
||||||
|
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
|
||||||
|
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
|
||||||
|
[2]string{hourPeriod, "2026-03-04-06"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
|
first := env.deliverReceivedAt(t, tgt, tc.before)
|
||||||
|
second := env.deliverReceivedAt(t, tgt, tc.after)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{first},
|
||||||
|
archivedEventIDs(t, periodPath(path, tc.periods[0])))
|
||||||
|
assert.Equal(t, []string{second},
|
||||||
|
archivedEventIDs(t, periodPath(path, tc.periods[1])))
|
||||||
|
assert.NoFileExists(t, path,
|
||||||
|
"a rotated target never writes the file without a period")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run(rotationNone, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
|
||||||
|
|
||||||
|
first := env.deliverReceivedAt(t, tgt, cases[0].before)
|
||||||
|
second := env.deliverReceivedAt(t, tgt, cases[0].after)
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, []string{first, second},
|
||||||
|
archivedEventIDs(t, env.archivePath(tgt)))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_EventWithoutReceiveTime proves an event whose
|
||||||
|
// receive time is not known goes to the file for the time it is
|
||||||
|
// archived, rather than to one for the year 1.
|
||||||
|
func TestDeliverDatabase_EventWithoutReceiveTime(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
|
||||||
|
before := time.Now().UTC().Format("2006-01")
|
||||||
|
id := env.deliverReceivedAt(t, tgt, time.Time{})
|
||||||
|
|
||||||
|
file := periodPath(path, time.Now().UTC().Format("2006-01"))
|
||||||
|
|
||||||
|
_, err := os.Stat(file)
|
||||||
|
if err != nil {
|
||||||
|
// The month turned during the delivery.
|
||||||
|
file = periodPath(path, before)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
|
||||||
|
assert.NoFileExists(t, periodPath(path, "0001-01"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeliverDatabase_RotationChangeKeepsOldFiles changes a target's
|
||||||
|
// rotation from none to daily between two events, and checks the
|
||||||
|
// second goes to the daily file while the first stays where it was.
|
||||||
|
func TestDeliverDatabase_RotationChangeKeepsOldFiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
first := env.deliverReceivedAt(t, tgt, at)
|
||||||
|
|
||||||
|
tgt.Config = dailyConfig
|
||||||
|
second := env.deliverReceivedAt(t, tgt, at)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
|
||||||
|
assert.Equal(t, []string{second},
|
||||||
|
archivedEventIDs(t, periodPath(path, dayPeriod)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
|
||||||
|
// the file without a period, left from before it rotated, and two
|
||||||
|
// daily files. Each holds a row older than the expiry, and one daily
|
||||||
|
// file also a newer row. The sweep prunes the old row from every file,
|
||||||
|
// deletes the daily file it leaves empty, and keeps the file without a
|
||||||
|
// period although it is empty too.
|
||||||
|
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(
|
||||||
|
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
|
||||||
|
)
|
||||||
|
path := env.archivePath(tgt)
|
||||||
|
emptied := periodPath(path, dayPeriod)
|
||||||
|
kept := periodPath(path, nextDayPeriod)
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
old := now.Add(-48 * time.Hour)
|
||||||
|
|
||||||
|
seedArchiveFile(t, path, tgt.WebhookID, old)
|
||||||
|
seedArchiveFile(t, emptied, tgt.WebhookID, old)
|
||||||
|
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
|
||||||
|
|
||||||
|
env.sweeper.ExportSweep(t.Context())
|
||||||
|
|
||||||
|
assert.Empty(t, archivedEventIDs(t, path))
|
||||||
|
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
|
||||||
|
|
||||||
|
for _, suffix := range archiveFileSuffixes() {
|
||||||
|
assert.NoFileExists(t, emptied+suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_MovesEveryFile renames a daily target that also has a
|
||||||
|
// file without a period, and checks every file moves to the new name
|
||||||
|
// with its period, rows and all, and that a later write uses the new
|
||||||
|
// name.
|
||||||
|
func TestRename_MovesEveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, "")
|
||||||
|
oldPath := env.archivePath(tgt)
|
||||||
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
unrotated := env.deliverReceivedAt(t, tgt, day)
|
||||||
|
|
||||||
|
tgt.Config = dailyConfig
|
||||||
|
first := env.deliverReceivedAt(t, tgt, day)
|
||||||
|
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
||||||
|
|
||||||
|
require.NoError(t, env.eng.Rename(tgt.ID, "Orders", "Long Term"))
|
||||||
|
|
||||||
|
newPath := filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, old := range []string{
|
||||||
|
oldPath,
|
||||||
|
periodPath(oldPath, dayPeriod),
|
||||||
|
periodPath(oldPath, nextDayPeriod),
|
||||||
|
} {
|
||||||
|
assert.NoFileExists(t, old)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
|
||||||
|
assert.Equal(t, []string{first},
|
||||||
|
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
|
||||||
|
assert.Equal(t, []string{second},
|
||||||
|
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
|
||||||
|
|
||||||
|
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
|
||||||
|
assert.Equal(t, []string{third},
|
||||||
|
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRename_NeverReplacesARotatedFile plants a file at the new name
|
||||||
|
// of a target's daily file, and proves the rename is refused and moves
|
||||||
|
// none of the target's files.
|
||||||
|
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupArchiveTest(t)
|
||||||
|
tgt := env.seedDatabaseTarget(t, dailyConfig)
|
||||||
|
oldPath := env.archivePath(tgt)
|
||||||
|
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
env.deliverReceivedAt(t, tgt, day)
|
||||||
|
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
|
||||||
|
|
||||||
|
newPath := filepath.Join(
|
||||||
|
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
|
||||||
|
)
|
||||||
|
planted := periodPath(newPath, nextDayPeriod)
|
||||||
|
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
|
||||||
|
|
||||||
|
require.ErrorIs(
|
||||||
|
t, env.eng.Rename(tgt.ID, "Orders", "Long Term"),
|
||||||
|
delivery.ErrArchiveNameTaken,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.FileExists(t, periodPath(oldPath, dayPeriod))
|
||||||
|
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
|
||||||
|
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStatArchive_EveryFile proves StatArchive counts and adds up every
|
||||||
|
// one of a target's files, takes the latest write of any of them, and
|
||||||
|
// leaves out files whose names only look like the target's.
|
||||||
|
func TestStatArchive_EveryFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "archive-wh.db")
|
||||||
|
files := []string{
|
||||||
|
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
|
||||||
|
}
|
||||||
|
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
|
||||||
|
|
||||||
|
var size int64
|
||||||
|
|
||||||
|
for i, file := range files {
|
||||||
|
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
|
||||||
|
|
||||||
|
size += int64(100 * (i + 1))
|
||||||
|
at := written.Add(-time.Duration(i) * time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(file, at, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, other := range []string{
|
||||||
|
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
|
||||||
|
"archive-wh-other.db", "archive-wh-2026-03.json",
|
||||||
|
"archive-whx.db",
|
||||||
|
} {
|
||||||
|
require.NoError(t,
|
||||||
|
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := delivery.StatArchive(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, len(files), got.Files)
|
||||||
|
assert.Equal(t, size, got.Size)
|
||||||
|
assert.True(t, written.Equal(got.Written), got.Written)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchivePathAt names the file each rotation writes an event to.
|
||||||
|
func TestArchivePathAt(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
dbMgr := databasetest.NewWebhookDBManager(t, dataDir)
|
||||||
|
webhook := &database.Webhook{
|
||||||
|
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
|
||||||
|
}
|
||||||
|
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
cases := map[string]string{
|
||||||
|
"": "",
|
||||||
|
`{"rotation":"` + rotationNone + `"}`: "",
|
||||||
|
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
|
||||||
|
dailyConfig: "-2026-10-01",
|
||||||
|
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
|
||||||
|
}
|
||||||
|
|
||||||
|
for config, period := range cases {
|
||||||
|
target := &database.Target{
|
||||||
|
BaseModel: database.BaseModel{ID: "tgt-id"},
|
||||||
|
Name: "Archive",
|
||||||
|
Config: config,
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := delivery.ArchivePathAt(dbMgr, webhook, target, at)
|
||||||
|
require.NoError(t, err, config)
|
||||||
|
assert.Equal(t,
|
||||||
|
filepath.Join(
|
||||||
|
dataDir, "archive-orders-archive-tgt-id"+period+".db",
|
||||||
|
),
|
||||||
|
got, config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := delivery.ArchivePathAt(dbMgr, webhook, &database.Target{
|
||||||
|
Config: weeklyConfig,
|
||||||
|
}, at)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidateArchiveRotation accepts the four rotations, and empty,
|
||||||
|
// and refuses anything else.
|
||||||
|
func TestValidateArchiveRotation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, ok := range []string{
|
||||||
|
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
|
||||||
|
} {
|
||||||
|
require.NoError(t, delivery.ValidateArchiveRotation(ok), ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, bad := range []string{"weekly", "Daily", "hourly "} {
|
||||||
|
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package delivery_test
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -17,6 +18,7 @@ import (
|
|||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
"sneak.berlin/go/webhooker/internal/gormlog"
|
||||||
)
|
)
|
||||||
|
|
||||||
func archiveTestLogger() *slog.Logger {
|
func archiveTestLogger() *slog.Logger {
|
||||||
@@ -42,7 +44,8 @@ func openArchiveDBForRead(
|
|||||||
t.Cleanup(func() { _ = sqlDB.Close() })
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
||||||
|
|
||||||
gdb, err := gorm.Open(
|
gdb, err := gorm.Open(
|
||||||
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
|
sqlite.Dialector{Conn: sqlDB},
|
||||||
|
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -82,6 +85,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|||||||
|
|
||||||
webhookDB := testWebhookDB(t)
|
webhookDB := testWebhookDB(t)
|
||||||
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
event := seedEvent(t, webhookDB, `{"archived":true}`)
|
||||||
|
event.RawQuery = eventQuery
|
||||||
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt)
|
||||||
|
|
||||||
env.eng.ExportDeliverDatabase(webhookDB, d)
|
env.eng.ExportDeliverDatabase(webhookDB, d)
|
||||||
@@ -110,6 +114,7 @@ func TestDeliverDatabase_ArchivesEvent(t *testing.T) {
|
|||||||
assert.Equal(t, event.ID, rows[0].EventID)
|
assert.Equal(t, event.ID, rows[0].EventID)
|
||||||
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
assert.Equal(t, event.WebhookID, rows[0].WebhookID)
|
||||||
assert.Equal(t, event.Method, rows[0].Method)
|
assert.Equal(t, event.Method, rows[0].Method)
|
||||||
|
assert.Equal(t, eventQuery, rows[0].RawQuery)
|
||||||
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
assert.JSONEq(t, `{"archived":true}`, rows[0].Body)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +186,50 @@ func TestArchiveWriter_RecreatesAfterRemoval(
|
|||||||
assert.Equal(t, "b", got[0].EventID)
|
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) {
|
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -674,3 +723,41 @@ func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
|
|||||||
assert.NoFileExists(t, filepath.Join(dir, newName))
|
assert.NoFileExists(t, filepath.Join(dir, newName))
|
||||||
assert.Equal(t, oldPath, w.Path())
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, oldPath, w.Path())
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,10 +204,11 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
|
|
||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"720h"}`,
|
Config: `{"expiry":"720h","rotation":"daily"}`,
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "720h", form.Expiry)
|
assert.Equal(t, "720h", form.Expiry)
|
||||||
|
assert.Equal(t, rotationDaily, form.Rotation)
|
||||||
|
|
||||||
form, err = delivery.NewTargetConfigForm(&database.Target{
|
form, err = delivery.NewTargetConfigForm(&database.Target{
|
||||||
Type: database.TargetTypeLog,
|
Type: database.TargetTypeLog,
|
||||||
@@ -216,8 +217,9 @@ func TestNewTargetConfigForm(t *testing.T) {
|
|||||||
assert.Empty(t, form.URL)
|
assert.Empty(t, form.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A keep-forever archive target must pre-fill as an empty field, so
|
// A keep-forever archive target yields an empty expiry, so the edit
|
||||||
// saving the form back unchanged stores the same empty config.
|
// form starts on never; saving it unchanged stores never, which means
|
||||||
|
// the same as an empty expiry.
|
||||||
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
func TestNewTargetConfigForm_DatabaseNeverIsBlank(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -247,6 +249,10 @@ func TestNewTargetConfigForm_UnreadableConfigErrors(t *testing.T) {
|
|||||||
Type: database.TargetTypeDatabase,
|
Type: database.TargetTypeDatabase,
|
||||||
Config: `{"expiry":"soon"}`,
|
Config: `{"expiry":"soon"}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Type: database.TargetTypeDatabase,
|
||||||
|
Config: weeklyConfig,
|
||||||
|
},
|
||||||
{Type: database.TargetType("nope")},
|
{Type: database.TargetType("nope")},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -32,6 +33,11 @@ type HTTPTargetConfig struct {
|
|||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
Headers map[string]string `json:"headers,omitempty"`
|
Headers map[string]string `json:"headers,omitempty"`
|
||||||
Timeout int `json:"timeout,omitempty"`
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// ForwardQuery passes each event's query string on to the target,
|
||||||
|
// appended to URL. Off, the target URL is sent exactly as
|
||||||
|
// configured.
|
||||||
|
ForwardQuery bool `json:"forwardQuery,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// httpCore holds the retry, backoff, and circuit-breaker
|
// httpCore holds the retry, backoff, and circuit-breaker
|
||||||
@@ -234,7 +240,7 @@ func (c *httpCore) handleRetry(
|
|||||||
database.DeliveryStatusRetrying,
|
database.DeliveryStatusRetrying,
|
||||||
)
|
)
|
||||||
|
|
||||||
backoff := calcBackoff(attemptNum)
|
backoff := Backoff(attemptNum)
|
||||||
|
|
||||||
retryTask := *task
|
retryTask := *task
|
||||||
retryTask.AttemptNum = attemptNum + 1
|
retryTask.AttemptNum = attemptNum + 1
|
||||||
@@ -301,7 +307,7 @@ func (c *httpCore) remainingBackoff(
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := calcBackoff(attemptNum)
|
backoff := Backoff(attemptNum)
|
||||||
elapsed := time.Since(lastResult.CreatedAt)
|
elapsed := time.Since(lastResult.CreatedAt)
|
||||||
remaining := backoff - elapsed
|
remaining := backoff - elapsed
|
||||||
|
|
||||||
@@ -326,12 +332,14 @@ func (c *httpCore) backoffElapsed(
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
backoff := calcBackoff(attemptNum)
|
backoff := Backoff(attemptNum)
|
||||||
|
|
||||||
return time.Since(lastResult.CreatedAt) >= backoff
|
return time.Since(lastResult.CreatedAt) >= backoff
|
||||||
}
|
}
|
||||||
|
|
||||||
func calcBackoff(attemptNum int) time.Duration {
|
// 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 {
|
||||||
shift := max(attemptNum-1, 0)
|
shift := max(attemptNum-1, 0)
|
||||||
shift = min(shift, maxBackoffShift)
|
shift = min(shift, maxBackoffShift)
|
||||||
|
|
||||||
@@ -442,6 +450,10 @@ func (t *httpTarget) doHTTPRequest(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if cfg.ForwardQuery {
|
||||||
|
appendQuery(req.URL, event.RawQuery)
|
||||||
|
}
|
||||||
|
|
||||||
originScoped := applyRequestHeaders(
|
originScoped := applyRequestHeaders(
|
||||||
req, event, cfg, t.eng.userAgent(),
|
req, event, cfg, t.eng.userAgent(),
|
||||||
)
|
)
|
||||||
@@ -472,6 +484,19 @@ func (t *httpTarget) doHTTPRequest(
|
|||||||
return resp.StatusCode, string(body), dur, nil
|
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.
|
// clientForRequest returns the client for one delivery attempt.
|
||||||
// originScoped is the header set applyRequestHeaders built for that
|
// originScoped is the header set applyRequestHeaders built for that
|
||||||
// attempt; a request with neither a per-target timeout nor an
|
// attempt; a request with neither a per-target timeout nor an
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
package delivery_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"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"
|
||||||
|
|
||||||
|
// httpTargetConfig is the stored configuration of an HTTP target at
|
||||||
|
// targetURL.
|
||||||
|
func httpTargetConfig(
|
||||||
|
t *testing.T, targetURL string, forwardQuery bool,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
|
||||||
|
URL: targetURL, ForwardQuery: forwardQuery,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return string(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliverWithQuery sends one event that arrived with eventQuery to an
|
||||||
|
// HTTP target configured with cfg, through the path a received event's
|
||||||
|
// delivery takes, and returns the attempt it recorded.
|
||||||
|
func deliverWithQuery(t *testing.T, cfg string) 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", cfg, 0, 1, &event.Body,
|
||||||
|
)
|
||||||
|
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, httpTargetConfig(
|
||||||
|
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,
|
||||||
|
Config: httpTargetConfig(t, ts.URL+"/in?token="+secret, true),
|
||||||
|
}
|
||||||
|
|
||||||
|
deliverWithQuery(t, target.Config)
|
||||||
|
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, httpTargetConfig(
|
||||||
|
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
|
// logTarget is a fire-and-forget target that logs the entire
|
||||||
// inbound webhook — the full request body and headers, plus
|
// inbound webhook — the full request body, query string and
|
||||||
// the method, content type, and the webhook and entrypoint
|
// headers, plus the method, content type, and the webhook and
|
||||||
// ids — then records a single successful attempt.
|
// entrypoint ids — then records a single successful attempt.
|
||||||
//
|
//
|
||||||
// This is the one log call in the service that deliberately writes
|
// This is the one log call in the service that deliberately writes
|
||||||
// unbounded client-chosen bytes, so it is the one exception to the
|
// 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,
|
"webhook_id", d.Event.WebhookID,
|
||||||
"entrypoint_id", d.Event.EntrypointID,
|
"entrypoint_id", d.Event.EntrypointID,
|
||||||
"method", d.Event.Method,
|
"method", d.Event.Method,
|
||||||
|
"raw_query", d.Event.RawQuery,
|
||||||
"content_type", d.Event.ContentType,
|
"content_type", d.Event.ContentType,
|
||||||
"headers", d.Event.Headers,
|
"headers", d.Event.Headers,
|
||||||
"body", d.Event.Body,
|
"body", d.Event.Body,
|
||||||
|
|||||||
@@ -162,18 +162,22 @@ func targetSecrets(t *database.Target) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// urlSecrets returns the substrings of a destination URL that
|
// urlSecrets returns the substrings of a destination URL that
|
||||||
// must not survive into a rendered page: the whole URL, the
|
// must not survive into a rendered page: the whole URL; its
|
||||||
// parts of it MaskURL elides, and any userinfo.
|
// path, unless that is empty or "/"; its query string, and the
|
||||||
|
// request URI that carries it, which a remote echoing the
|
||||||
|
// request line shows even when the URL has no path; and its
|
||||||
|
// userinfo and password.
|
||||||
//
|
//
|
||||||
// No length floor is applied to the path, and none to the
|
// No length floor is applied to the path, the query string or
|
||||||
// userinfo. A short path or a four-byte username is treated as
|
// the userinfo. A short path or a four-byte username is
|
||||||
// a credential exactly like a long one, because the field takes
|
// treated as a credential exactly like a long one, because the
|
||||||
// an arbitrary URL and no part of it can be assumed non-secret —
|
// field takes an arbitrary URL and no part of it can be
|
||||||
// the same rule MaskURL applies. headerSecrets does carry a
|
// assumed non-secret — the same rule MaskURL applies.
|
||||||
// floor, and the difference is deliberate: a header is picked
|
// headerSecrets does carry a floor, and the difference is
|
||||||
// out by a name-shaped guess and its value may be ordinary
|
// deliberate: a header is picked out by a name-shaped guess
|
||||||
// text, whereas a URL's path and userinfo are credential
|
// and its value may be ordinary text, whereas a URL's path,
|
||||||
// material by position.
|
// query string and userinfo are credential material by
|
||||||
|
// position.
|
||||||
func urlSecrets(raw string) []string {
|
func urlSecrets(raw string) []string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -188,12 +192,11 @@ func urlSecrets(raw string) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if parsed.Path != "" && parsed.Path != "/" {
|
if parsed.Path != "" && parsed.Path != "/" {
|
||||||
requestURI := parsed.RequestURI()
|
secrets = append(secrets, parsed.EscapedPath())
|
||||||
secrets = append(secrets, requestURI)
|
|
||||||
|
|
||||||
if escaped := parsed.EscapedPath(); escaped != requestURI {
|
|
||||||
secrets = append(secrets, escaped)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if parsed.RawQuery != "" {
|
||||||
|
secrets = append(secrets, parsed.RequestURI(), parsed.RawQuery)
|
||||||
}
|
}
|
||||||
|
|
||||||
if parsed.User != nil {
|
if parsed.User != nil {
|
||||||
|
|||||||
@@ -202,6 +202,48 @@ func TestRedactor_RemovesHTTPURLQueryAndUserinfo(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRedactor_RemovesEchoedQueryOfURLWithoutPath covers an
|
||||||
|
// HTTP target URL whose credential is all in its query string.
|
||||||
|
// Written with or without the "/", the request line sends it
|
||||||
|
// as "/?token=…", and a target passing the event's query string
|
||||||
|
// on sends that after an "&". The event's part stays visible:
|
||||||
|
// the event's page shows it anyway.
|
||||||
|
func TestRedactor_RemovesEchoedQueryOfURLWithoutPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const secret = "s3cr3t"
|
||||||
|
|
||||||
|
marker := delivery.RedactionMarker
|
||||||
|
|
||||||
|
// An echoed request line, and what the event log shows of it.
|
||||||
|
echoes := map[string]string{
|
||||||
|
"POST /?token=" + secret + " HTTP/1.1": "POST " + marker +
|
||||||
|
" HTTP/1.1",
|
||||||
|
"POST ?token=" + secret + " HTTP/1.1": "POST ?" + marker +
|
||||||
|
" HTTP/1.1",
|
||||||
|
"POST /?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST " +
|
||||||
|
marker + "&a=1&b=2 HTTP/1.1",
|
||||||
|
"POST ?token=" + secret + "&a=1&b=2 HTTP/1.1": "POST ?" +
|
||||||
|
marker + "&a=1&b=2 HTTP/1.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dest := range []string{
|
||||||
|
"https://example.com/?token=" + secret,
|
||||||
|
"https://example.com?token=" + secret,
|
||||||
|
} {
|
||||||
|
r := delivery.NewRedactor(&database.Target{
|
||||||
|
Type: database.TargetTypeHTTP,
|
||||||
|
Config: `{"url":"` + dest + `"}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
for echoed, want := range echoes {
|
||||||
|
assert.Equal(
|
||||||
|
t, want, r.Redact(echoed), "%s: %s", dest, echoed,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRedactor_LeavesUnrelatedTextAlone pins that the
|
// TestRedactor_LeavesUnrelatedTextAlone pins that the
|
||||||
// redactor matches literally: it does not guess at what a
|
// redactor matches literally: it does not guess at what a
|
||||||
// secret looks like, so ordinary response content survives.
|
// secret looks like, so ordinary response content survives.
|
||||||
|
|||||||
@@ -573,6 +573,8 @@ func TestRecoverPending_TargetDeleted(t *testing.T) {
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(t, s.MainDB, s.WebhookID, "pending-recovery")
|
||||||
|
|
||||||
deliveryID := tSeedDeletedTarget(
|
deliveryID := tSeedDeletedTarget(
|
||||||
t, s, "gone-while-pending", "http://example.com/hook",
|
t, s, "gone-while-pending", "http://example.com/hook",
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
@@ -612,6 +614,8 @@ func TestRecoverPending_TargetDeleted_LeavesAnOwnedDeliveryAlone(
|
|||||||
|
|
||||||
s := newISetup(t)
|
s := newISetup(t)
|
||||||
|
|
||||||
|
iCreateWebhook(t, s.MainDB, s.WebhookID, "owned-recovery")
|
||||||
|
|
||||||
deliveryID := tSeedDeletedTarget(
|
deliveryID := tSeedDeletedTarget(
|
||||||
t, s, "gone-but-owned", "http://example.com/hook",
|
t, s, "gone-but-owned", "http://example.com/hook",
|
||||||
database.DeliveryStatusPending,
|
database.DeliveryStatusPending,
|
||||||
|
|||||||
@@ -179,6 +179,27 @@ func TestDoHTTPRequest_TransportErrorMasksURL(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDoHTTPRequest_UnparsableURLIsMasked is the same for an HTTP
|
||||||
|
// target URL that no request can be built from.
|
||||||
|
func TestDoHTTPRequest_UnparsableURLIsMasked(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
e := testEngine(t, 1)
|
||||||
|
|
||||||
|
statusCode, _, _, reqErr := e.ExportDoHTTPRequest(
|
||||||
|
context.TODO(),
|
||||||
|
&delivery.HTTPTargetConfig{
|
||||||
|
URL: "https://hooks.example.com" + maskSecretPath + "\n",
|
||||||
|
},
|
||||||
|
&database.Event{},
|
||||||
|
)
|
||||||
|
require.Error(t, reqErr)
|
||||||
|
assert.Zero(t, statusCode)
|
||||||
|
|
||||||
|
assertNoCredential(t, reqErr.Error())
|
||||||
|
assert.Contains(t, reqErr.Error(), "invalid control character")
|
||||||
|
}
|
||||||
|
|
||||||
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
// TestValidateTargetURL_UnparsableURLIsMasked proves the SSRF
|
||||||
// validator's error does not carry the submitted URL, which
|
// validator's error does not carry the submitted URL, which
|
||||||
// the handler both logs and shows.
|
// the handler both logs and shows.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package gormlog_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -13,6 +14,7 @@ import (
|
|||||||
"go.uber.org/fx/fxtest"
|
"go.uber.org/fx/fxtest"
|
||||||
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
_ "modernc.org/sqlite" // Pure Go SQLite driver.
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config/configtest"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
@@ -128,7 +130,7 @@ func readFirstBootSecrets(
|
|||||||
func bootAtDebug(t *testing.T, dataDir string) string {
|
func bootAtDebug(t *testing.T, dataDir string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
config.ClearEnvForTest(t)
|
configtest.ClearEnv(t)
|
||||||
t.Setenv("DEBUG", "true")
|
t.Setenv("DEBUG", "true")
|
||||||
t.Setenv("DATA_DIR", dataDir)
|
t.Setenv("DATA_DIR", dataDir)
|
||||||
|
|
||||||
@@ -145,6 +147,7 @@ func bootAtDebug(t *testing.T, dataDir string) string {
|
|||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
func(l *logger.Logger) *slog.Logger { return l.Get() },
|
||||||
config.New,
|
config.New,
|
||||||
database.New,
|
database.New,
|
||||||
session.New,
|
session.New,
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ func (l *Logger) LogMode(gormlogger.LogLevel) gormlogger.Interface {
|
|||||||
//
|
//
|
||||||
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
// One GORM path does not consult this: (*gorm.DB).Scan records the
|
||||||
// statement through gorm's own traceRecorder, which does not implement
|
// statement through gorm's own traceRecorder, which does not implement
|
||||||
// this interface. No production code path calls it; its one caller is
|
// this interface. No production code path calls it; only tests do, and
|
||||||
// internal/database/database_test.go:91, whose SELECT 1 binds nothing.
|
// what a test binds is fixture data. scan_guard_test.go fails if a
|
||||||
// scan_guard_test.go fails if a non-test file calls it.
|
// non-test file calls it.
|
||||||
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
// (*gorm.DB).Pluck, Row and Raw all run through the normal callback
|
||||||
// processor and are filtered.
|
// processor and are filtered.
|
||||||
func (l *Logger) ParamsFilter(
|
func (l *Logger) ParamsFilter(
|
||||||
|
|||||||
@@ -14,15 +14,16 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// isRowProducer reports whether name is one of the GORM and
|
// isRowProducer reports whether name is GORM's Row or database/sql's
|
||||||
// database/sql methods that return a row handle, whose Scan is
|
// QueryRow or QueryRowContext, which return a *sql.Row whose Scan is
|
||||||
// database/sql's and not (*gorm.DB).Scan. It matches the method name
|
// database/sql's and not (*gorm.DB).Scan. GORM's Rows is not listed:
|
||||||
// only and resolves no types, so a repo-local method with one of these
|
// it also returns an error, so Scan is never called on its result
|
||||||
// names that returns *gorm.DB gets past it: Scan on that method's
|
// directly. It matches the method name only and resolves no types, so
|
||||||
// result is not reported.
|
// a repo-local method with one of these names that returns *gorm.DB
|
||||||
|
// gets past it: Scan on that method's result is not reported.
|
||||||
func isRowProducer(name string) bool {
|
func isRowProducer(name string) bool {
|
||||||
switch name {
|
switch name {
|
||||||
case "Row", "Rows", "QueryRow", "QueryRowContext":
|
case "Row", "QueryRow", "QueryRowContext":
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
@@ -203,9 +204,12 @@ func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
|||||||
root := moduleRoot(t)
|
root := moduleRoot(t)
|
||||||
walked, offenders := walkNonTestGo(t, root)
|
walked, offenders := walkNonTestGo(t, root)
|
||||||
|
|
||||||
// Every directory directly under cmd and internal is a package with
|
// The module's packages are static, templates, and every directory
|
||||||
// non-test code, so one the walk parsed nothing in was skipped, and
|
// directly under cmd and internal. Each holds non-test code, so one
|
||||||
// a Scan there would pass unseen.
|
// the walk parsed nothing in was skipped, and a Scan there would
|
||||||
|
// pass unseen.
|
||||||
|
packages := []string{"static", "templates"}
|
||||||
|
|
||||||
for _, parent := range []string{"cmd", "internal"} {
|
for _, parent := range []string{"cmd", "internal"} {
|
||||||
entries, err := os.ReadDir(filepath.Join(root, parent))
|
entries, err := os.ReadDir(filepath.Join(root, parent))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
@@ -215,13 +219,16 @@ func TestGormScanIsNeverCalledOutsideTests(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
dir := filepath.Join(parent, entry.Name())
|
packages = append(packages, filepath.Join(parent, entry.Name()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dir := range packages {
|
||||||
require.True(
|
require.True(
|
||||||
t, walked[dir],
|
t, walked[dir],
|
||||||
"the walk parsed no non-test .go file in %s", dir,
|
"the walk parsed no non-test .go file in %s", dir,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
require.Empty(
|
require.Empty(
|
||||||
t, offenders,
|
t, offenders,
|
||||||
@@ -244,8 +251,8 @@ type scanGuardCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// scanGuardCases covers each receiver form unguardedScans names, plus
|
// scanGuardCases covers each receiver form unguardedScans names, plus
|
||||||
// the row producers it lets through. Each body is valid Go inside
|
// each row producer isRowProducer lets through. Each body is valid Go
|
||||||
// plantedFile.
|
// inside plantedFile.
|
||||||
func scanGuardCases() []scanGuardCase {
|
func scanGuardCases() []scanGuardCase {
|
||||||
return []scanGuardCase{
|
return []scanGuardCase{
|
||||||
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
|
{"local variable", "q := gdb.Raw(\"SELECT 1\")\n\tq.Scan(&v)", 1},
|
||||||
@@ -258,6 +265,11 @@ func scanGuardCases() []scanGuardCase {
|
|||||||
},
|
},
|
||||||
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
{"gorm Row", `gdb.Raw("SELECT 1").Row().Scan(&v)`, 0},
|
||||||
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
|
{"sql QueryRow", `sqlDB.QueryRow("SELECT 1").Scan(&v)`, 0},
|
||||||
|
{
|
||||||
|
"sql QueryRowContext",
|
||||||
|
`sqlDB.QueryRowContext(ctx, "SELECT 1").Scan(&v)`,
|
||||||
|
0,
|
||||||
|
},
|
||||||
{"unrelated call", `gdb.Find(&v)`, 0},
|
{"unrelated call", `gdb.Find(&v)`, 0},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,6 +280,7 @@ func scanGuardCases() []scanGuardCase {
|
|||||||
const plantedFile = `package p
|
const plantedFile = `package p
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -275,7 +288,7 @@ import (
|
|||||||
|
|
||||||
type store struct{ db *gorm.DB }
|
type store struct{ db *gorm.DB }
|
||||||
|
|
||||||
func f(gdb *gorm.DB, sqlDB *sql.DB, s store) {
|
func f(ctx context.Context, gdb *gorm.DB, sqlDB *sql.DB, s store) {
|
||||||
var v int
|
var v int
|
||||||
|
|
||||||
%s
|
%s
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
const (
|
||||||
|
// archiveExpiryNever is the archive expiry that keeps archived
|
||||||
|
// events forever. A stored empty expiry means the same.
|
||||||
|
archiveExpiryNever = "never"
|
||||||
|
|
||||||
|
// tmplKeyArchiveExpiryChoices is the template data key for the
|
||||||
|
// entries of a page's archive expiry select.
|
||||||
|
tmplKeyArchiveExpiryChoices = "ArchiveExpiryChoices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveChoice is one entry of a database target's archive expiry
|
||||||
|
// or archive rotation select: the value stored, the label shown, and
|
||||||
|
// whether the select starts on it.
|
||||||
|
type archiveChoice struct {
|
||||||
|
Value string
|
||||||
|
Label string
|
||||||
|
Selected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveExpiryChoices lists the archive expiries offered by the new
|
||||||
|
// webhook page, the add target form and the target edit form.
|
||||||
|
func archiveExpiryChoices() []archiveChoice {
|
||||||
|
return []archiveChoice{
|
||||||
|
{Value: archiveExpiryNever, Label: archiveExpiryNever},
|
||||||
|
{Value: "1h", Label: "1h"},
|
||||||
|
{Value: "12h", Label: "12h"},
|
||||||
|
{Value: "24h", Label: "24h"},
|
||||||
|
{Value: "720h", Label: "30d"},
|
||||||
|
{Value: "2160h", Label: "90d"},
|
||||||
|
{Value: "8760h", Label: "365d"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveExpiryOptions returns the choices with expiry selected; an
|
||||||
|
// empty expiry selects never. An expiry that is not one of the
|
||||||
|
// choices comes first as its own selected entry, so saving the form
|
||||||
|
// unchanged keeps it.
|
||||||
|
func archiveExpiryOptions(expiry string) []archiveChoice {
|
||||||
|
if expiry == "" {
|
||||||
|
expiry = archiveExpiryNever
|
||||||
|
}
|
||||||
|
|
||||||
|
options := archiveExpiryChoices()
|
||||||
|
|
||||||
|
for i := range options {
|
||||||
|
if options[i].Value == expiry {
|
||||||
|
options[i].Selected = true
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
own := archiveChoice{Value: expiry, Label: expiry, Selected: true}
|
||||||
|
|
||||||
|
return append([]archiveChoice{own}, options...)
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// expiryNever is the archive expiry that keeps archived events
|
||||||
|
// forever.
|
||||||
|
const expiryNever = "never"
|
||||||
|
|
||||||
|
// matched returns what the one group of pattern matched in page, at
|
||||||
|
// each match.
|
||||||
|
func matched(pattern, page string) []string {
|
||||||
|
matches := regexp.MustCompile(pattern).FindAllStringSubmatch(page, -1)
|
||||||
|
groups := make([]string, 0, len(matches))
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
groups = append(groups, m[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups
|
||||||
|
}
|
||||||
|
|
||||||
|
// expiryShown returns the archive expiries the webhook page's target
|
||||||
|
// list shows.
|
||||||
|
func expiryShown(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID string,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
|
||||||
|
t, "/hook/"+webhookID, env.cookies,
|
||||||
|
map[string]string{sourceIDParam: webhookID},
|
||||||
|
))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return matched(
|
||||||
|
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// expirySelected returns the target edit page and the expiries its
|
||||||
|
// expiry select starts on.
|
||||||
|
func expirySelected(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||||
|
) (string, []string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
page := targetEditPage(t, env, webhookID, targetID)
|
||||||
|
|
||||||
|
return page, selectedIn(page, "expiry")
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetEditPage returns a target's edit page.
|
||||||
|
func targetEditPage(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID, targetID string,
|
||||||
|
) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodGet,
|
||||||
|
"/hook/"+webhookID+"/targets/"+targetID+"/edit", nil,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectedIn returns the values the select named name on page starts
|
||||||
|
// on.
|
||||||
|
func selectedIn(page, name string) []string {
|
||||||
|
_, rest, _ := strings.Cut(page, `<select id="`+name+`" name="`+name+`"`)
|
||||||
|
options, _, _ := strings.Cut(rest, "</select>")
|
||||||
|
|
||||||
|
return matched(`<option value="([^"]*)" selected>`, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExpiryChoices adds a database target with each archive
|
||||||
|
// expiry the forms offer, and checks that it is stored as chosen,
|
||||||
|
// shown in plain units in the target list, and that the target edit
|
||||||
|
// form starts on it.
|
||||||
|
func TestArchiveExpiryChoices(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
choices := []struct{ value, shown string }{
|
||||||
|
{expiryNever, expiryNever},
|
||||||
|
{"1h", "1 hour"},
|
||||||
|
{"12h", "12 hours"},
|
||||||
|
{"24h", "1 day"},
|
||||||
|
{"720h", "30 days"},
|
||||||
|
{"2160h", "90 days"},
|
||||||
|
{"8760h", "365 days"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, choice := range choices {
|
||||||
|
t.Run(choice.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("type", string(database.TargetTypeDatabase))
|
||||||
|
form.Set("expiry", choice.value)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.JSONEq(
|
||||||
|
t, `{"expiry":"`+choice.value+`"}`, targets[0].Config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(
|
||||||
|
t, []string{choice.shown},
|
||||||
|
expiryShown(t, env, webhook.ID),
|
||||||
|
)
|
||||||
|
|
||||||
|
_, selected := expirySelected(t, env, webhook.ID, targets[0].ID)
|
||||||
|
assert.Equal(t, []string{choice.value}, selected)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveExpiryEditStartsOnStoredValue checks the edit form of a
|
||||||
|
// database target whose stored expiry is empty, which selects never,
|
||||||
|
// and of one whose expiry is not one of the choices, which is listed
|
||||||
|
// first as its own selected entry and saved unchanged.
|
||||||
|
func TestArchiveExpiryEditStartsOnStoredValue(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
empty := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
|
||||||
|
)
|
||||||
|
|
||||||
|
_, selected := expirySelected(t, env, webhook.ID, empty.ID)
|
||||||
|
assert.Equal(t, []string{expiryNever}, selected)
|
||||||
|
|
||||||
|
webhook = seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
unlisted := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
||||||
|
`{"expiry":"36h"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"36 hours"}, expiryShown(t, env, webhook.ID))
|
||||||
|
|
||||||
|
page, selected := expirySelected(t, env, webhook.ID, unlisted.ID)
|
||||||
|
assert.Equal(t, []string{"36h"}, selected)
|
||||||
|
assert.Regexp(
|
||||||
|
t,
|
||||||
|
`<select id="expiry" name="expiry" class="input">\s*`+
|
||||||
|
`<option value="36h" selected>36h</option>\s*`+
|
||||||
|
`<option value="never">never</option>`,
|
||||||
|
page,
|
||||||
|
)
|
||||||
|
assert.Contains(t, page, `<option value="8760h">365d</option>`)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", unlisted.Name)
|
||||||
|
form.Set("expiry", "36h")
|
||||||
|
|
||||||
|
w := submitTargetEdit(env, webhook.ID, unlisted.ID, form)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
assert.JSONEq(
|
||||||
|
t, `{"expiry":"36h"}`, storedTarget(t, env, unlisted.ID).Config,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
const (
|
||||||
|
// archiveRotationNone is the archive rotation that keeps a
|
||||||
|
// database target's archive in one file. A stored empty rotation
|
||||||
|
// means the same.
|
||||||
|
archiveRotationNone = "none"
|
||||||
|
|
||||||
|
// tmplKeyArchiveRotationChoices is the template data key for the
|
||||||
|
// entries of a page's archive rotation select.
|
||||||
|
tmplKeyArchiveRotationChoices = "ArchiveRotationChoices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// archiveRotationChoices lists the archive rotations offered by the
|
||||||
|
// new webhook page, the add target form and the target edit form.
|
||||||
|
func archiveRotationChoices() []archiveChoice {
|
||||||
|
return []archiveChoice{
|
||||||
|
{Value: archiveRotationNone, Label: archiveRotationNone},
|
||||||
|
{Value: "monthly", Label: "monthly"},
|
||||||
|
{Value: "daily", Label: "daily"},
|
||||||
|
{Value: "hourly", Label: "hourly"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// archiveRotationOptions returns the choices with rotation selected;
|
||||||
|
// an empty rotation, or one that is not a choice, selects none. A
|
||||||
|
// stored rotation is always a choice: the forms refuse any other.
|
||||||
|
func archiveRotationOptions(rotation string) []archiveChoice {
|
||||||
|
options := archiveRotationChoices()
|
||||||
|
|
||||||
|
for i := range options {
|
||||||
|
if options[i].Value == rotation {
|
||||||
|
options[i].Selected = true
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options[0].Selected = true
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rotationNone is the archive rotation that keeps one file.
|
||||||
|
const rotationNone = "none"
|
||||||
|
|
||||||
|
// rotationShown returns the archive rotations the webhook page's
|
||||||
|
// target list shows.
|
||||||
|
func rotationShown(
|
||||||
|
t *testing.T, env *sourceTestEnv, webhookID string,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return matched(
|
||||||
|
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
|
||||||
|
renderedPage(t, env, webhookID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveRotationChoices adds a database target with each archive
|
||||||
|
// rotation the forms offer, and checks that it is stored as chosen,
|
||||||
|
// shown in the target list, and that the target edit form starts on
|
||||||
|
// it. It then edits the target to hourly, keeping its expiry.
|
||||||
|
func TestArchiveRotationChoices(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
for _, rotation := range []string{rotationNone, "monthly", "daily", "hourly"} {
|
||||||
|
t.Run(rotation, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("type", string(database.TargetTypeDatabase))
|
||||||
|
form.Set("expiry", "720h")
|
||||||
|
form.Set("rotation", rotation)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"expiry":"720h","rotation":"`+rotation+`"}`,
|
||||||
|
targets[0].Config,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.Equal(t,
|
||||||
|
[]string{rotation}, rotationShown(t, env, webhook.ID))
|
||||||
|
|
||||||
|
page := targetEditPage(t, env, webhook.ID, targets[0].ID)
|
||||||
|
assert.Equal(t, []string{rotation}, selectedIn(page, "rotation"))
|
||||||
|
|
||||||
|
form = url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("expiry", "720h")
|
||||||
|
form.Set("rotation", "hourly")
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, webhook.ID, targets[0].ID, form)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"expiry":"720h","rotation":"hourly"}`,
|
||||||
|
storedTarget(t, env, targets[0].ID).Config,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveRotationEditStartsOnNone checks the edit form of a
|
||||||
|
// database target with no rotation stored starts on none.
|
||||||
|
func TestArchiveRotationEditStartsOnNone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
target := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase, "",
|
||||||
|
)
|
||||||
|
|
||||||
|
page := targetEditPage(t, env, webhook.ID, target.ID)
|
||||||
|
assert.Equal(t, []string{rotationNone}, selectedIn(page, "rotation"))
|
||||||
|
assert.Equal(t, []string{rotationNone}, rotationShown(t, env, webhook.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveRotationRefused proves a rotation that is not one of the
|
||||||
|
// four is refused on the add target form and the target edit form, and
|
||||||
|
// that nothing is stored.
|
||||||
|
func TestArchiveRotationRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "archive")
|
||||||
|
form.Set("type", string(database.TargetTypeDatabase))
|
||||||
|
form.Set("rotation", "weekly")
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodPost, "/hook/"+webhook.ID+"/targets", form,
|
||||||
|
)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
|
||||||
|
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
|
||||||
|
|
||||||
|
target := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
||||||
|
`{"rotation":"daily"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
form.Del("type")
|
||||||
|
|
||||||
|
w = submitTargetEdit(env, webhook.ID, target.ID, form)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "Invalid archive rotation")
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"rotation":"daily"}`, storedTarget(t, env, target.ID).Config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceCreateSubmit_ArchiveRotation proves the new webhook
|
||||||
|
// page's archive rotation is stored on the archive target it creates.
|
||||||
|
func TestHandleSourceCreateSubmit_ArchiveRotation(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("name", "rotated")
|
||||||
|
form.Set("archive", "on")
|
||||||
|
form.Set("archive_expiry", "720h")
|
||||||
|
form.Set("archive_rotation", "daily")
|
||||||
|
|
||||||
|
w := submitCreateForm(env, form)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, w.Body.String())
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().
|
||||||
|
Where("name = ?", "rotated").First(&webhook).Error)
|
||||||
|
|
||||||
|
targets := targetsForWebhook(t, env.db, webhook.ID)
|
||||||
|
require.Len(t, targets, 1)
|
||||||
|
assert.JSONEq(t,
|
||||||
|
`{"expiry":"720h","rotation":"daily"}`, targets[0].Config,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestArchiveFileView_Rotated describes a daily target's archive files
|
||||||
|
// at two times. On a day that has a file, the view names that file;
|
||||||
|
// on the next, before any event, it names the file the next event
|
||||||
|
// will go to, not created yet. Both times the size is of every file
|
||||||
|
// together and the last write the latest of them.
|
||||||
|
func TestArchiveFileView_Rotated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
target := seedConfiguredTarget(
|
||||||
|
t, env.db, webhook.ID, database.TargetTypeDatabase,
|
||||||
|
`{"rotation":"daily"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
path := delivery.ArchivePath(env.dbMgr, &webhook, target)
|
||||||
|
stem := strings.TrimSuffix(path, ".db")
|
||||||
|
written := time.Date(2026, 10, 2, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
for i, day := range []string{"2026-10-01", "2026-10-02"} {
|
||||||
|
file := stem + "-" + day + ".db"
|
||||||
|
require.NoError(t, os.WriteFile(file, make([]byte, 1000), 0o600))
|
||||||
|
|
||||||
|
at := written.Add(time.Duration(i-1) * 24 * time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(file, at, at))
|
||||||
|
}
|
||||||
|
|
||||||
|
view := env.handlers.ArchiveFileViewForTest(
|
||||||
|
&webhook, target, time.Date(2026, 10, 2, 23, 0, 0, 0, time.UTC),
|
||||||
|
)
|
||||||
|
assert.Equal(t, filepath.Base(stem)+"-2026-10-02.db", view.Name)
|
||||||
|
assert.Empty(t, view.Note)
|
||||||
|
assert.Equal(t, 2, view.Files)
|
||||||
|
assert.Equal(t, "2.0 kB", view.Size)
|
||||||
|
assert.Equal(t, "2026-10-02 09:00:00 UTC", view.WrittenUTC)
|
||||||
|
|
||||||
|
view = env.handlers.ArchiveFileViewForTest(
|
||||||
|
&webhook, target, time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC),
|
||||||
|
)
|
||||||
|
assert.Equal(t, filepath.Base(stem)+"-2026-10-03.db", view.Name)
|
||||||
|
assert.Equal(t, "not created yet", view.Note)
|
||||||
|
assert.Equal(t, 2, view.Files)
|
||||||
|
assert.Equal(t, "2.0 kB", view.Size)
|
||||||
|
|
||||||
|
page := targetList(t, renderedPage(t, env, webhook.ID))
|
||||||
|
assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderedPage returns the webhook page.
|
||||||
|
func renderedPage(t *testing.T, env *sourceTestEnv, webhookID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
env.handlers.HandleSourceDetail().ServeHTTP(w, getRequest(
|
||||||
|
t, "/hook/"+webhookID, env.cookies,
|
||||||
|
map[string]string{sourceIDParam: webhookID},
|
||||||
|
))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
return w.Body.String()
|
||||||
|
}
|
||||||
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
|
|||||||
)))
|
)))
|
||||||
h.renderLoginError(
|
h.renderLoginError(
|
||||||
w, r,
|
w, r,
|
||||||
"Too many failed login attempts. Please try again later.",
|
"Too many failed sign-in attempts. Please try again later.",
|
||||||
http.StatusTooManyRequests,
|
http.StatusTooManyRequests,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms proves, on the
|
||||||
|
// event's page and in the event log, that an http or slack attempt
|
||||||
|
// shows its status as before, while a database or log attempt, which
|
||||||
|
// sends no HTTP request, says what it did and shows no status.
|
||||||
|
func TestDeliveryAttempts_ReadInTheTargetTypesOwnTerms(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
targetType database.TargetType
|
||||||
|
success bool
|
||||||
|
statusCode int
|
||||||
|
errText string
|
||||||
|
outcome string
|
||||||
|
status string // "" when the attempt must show no status
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
database.TargetTypeHTTP, false, 0, "",
|
||||||
|
"failure", "Status: — (no response)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
database.TargetTypeSlack, true, http.StatusOK, "",
|
||||||
|
"success", "Status: 200",
|
||||||
|
},
|
||||||
|
{database.TargetTypeDatabase, true, 0, "", "archived", ""},
|
||||||
|
{
|
||||||
|
database.TargetTypeDatabase, false, 0,
|
||||||
|
"opening archive database: disk full", "failure", "",
|
||||||
|
},
|
||||||
|
{database.TargetTypeLog, true, 0, "", "written to the log", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(string(tc.targetType), func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, tc.targetType)
|
||||||
|
event := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||||
|
dlv := f.delivery(
|
||||||
|
t, event, target.ID, database.DeliveryStatusDelivered,
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(
|
||||||
|
&database.DeliveryResult{
|
||||||
|
DeliveryID: dlv.ID,
|
||||||
|
AttemptNum: 1,
|
||||||
|
Success: tc.success,
|
||||||
|
StatusCode: tc.statusCode,
|
||||||
|
Error: tc.errText,
|
||||||
|
},
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
pages := []string{
|
||||||
|
w.Body.String(),
|
||||||
|
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range pages {
|
||||||
|
assert.Contains(t, page, ">"+tc.outcome+"</span>")
|
||||||
|
|
||||||
|
if tc.errText != "" {
|
||||||
|
assert.Contains(t, page, "Error: "+tc.errText)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.status == "" {
|
||||||
|
assert.NotContains(t, page, "Status:")
|
||||||
|
} else {
|
||||||
|
assert.Contains(t, page, tc.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,7 +20,9 @@ const (
|
|||||||
// replayTargetDeleted reports a target that once existed and has
|
// replayTargetDeleted reports a target that once existed and has
|
||||||
// since been deleted. Deletes are soft and deliveries carry no
|
// since been deleted. Deletes are soft and deliveries carry no
|
||||||
// foreign key to the target row, so the history survives its
|
// foreign key to the target row, so the history survives its
|
||||||
// target and this is the ordinary case for an old event.
|
// target and this is the ordinary case for an old event. The
|
||||||
|
// event log shows no Replay button for such a delivery, so only
|
||||||
|
// a page loaded before the delete reaches this.
|
||||||
replayTargetDeleted noticeCode = "replay-target-deleted"
|
replayTargetDeleted noticeCode = "replay-target-deleted"
|
||||||
|
|
||||||
// replayTargetMissing reports a target id that names no row at
|
// replayTargetMissing reports a target id that names no row at
|
||||||
@@ -281,6 +282,7 @@ func createReplayDelivery(
|
|||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
TargetID: target.ID,
|
TargetID: target.ID,
|
||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
|
Replay: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
||||||
@@ -308,6 +310,7 @@ func createReplayDelivery(
|
|||||||
TargetConfig: target.Config,
|
TargetConfig: target.Config,
|
||||||
MaxRetries: target.MaxRetries,
|
MaxRetries: target.MaxRetries,
|
||||||
Method: event.Method,
|
Method: event.Method,
|
||||||
|
RawQuery: event.RawQuery,
|
||||||
Headers: event.Headers,
|
Headers: event.Headers,
|
||||||
ContentType: event.ContentType,
|
ContentType: event.ContentType,
|
||||||
Body: replayBody(event.Body),
|
Body: replayBody(event.Body),
|
||||||
@@ -327,24 +330,25 @@ func replayBody(body string) *string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// redirectToEventLog redirects a replay or resubmit back to the event
|
// redirectToEventLog redirects a replay or resubmit back to the event
|
||||||
// log it was triggered from, carrying the outcome as its notice and
|
// log it was triggered from, carrying the outcome as its notice. A
|
||||||
// the page number the form submitted.
|
// Replay form carries the list it was pressed in as show, so a replay
|
||||||
|
// returns to the Failed or Pending list; a Resubmit form carries none,
|
||||||
|
// so a resubmit returns to the full log, where its new event is the
|
||||||
|
// newest.
|
||||||
func redirectToEventLog(
|
func redirectToEventLog(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
code noticeCode,
|
code noticeCode,
|
||||||
) {
|
) {
|
||||||
dest := withNotice("/hook/"+webhook.ID+"/events", code)
|
location := withNotice("/hook/"+webhook.ID+"/events", code)
|
||||||
|
|
||||||
// The page is read from the form rather than the query string:
|
show := r.PostFormValue(showParam)
|
||||||
// this is a POST, and its query string is what logs and Referer
|
if eventLogStatuses(show) != nil {
|
||||||
// headers record.
|
location += "&" + showParam + "=" + show
|
||||||
if page := pageOrFirst(
|
|
||||||
r.PostFormValue("page"),
|
|
||||||
); page > 1 {
|
|
||||||
dest += "&page=" + strconv.Itoa(page)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
http.Redirect(w, r, dest, http.StatusSeeOther)
|
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
|
||||||
|
w, r, location, http.StatusSeeOther,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package handlers_test
|
package handlers_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -24,6 +26,9 @@ const paramDeliveryID = "deliveryID"
|
|||||||
// dispatches to it: the notifier is recorded, not run.
|
// dispatches to it: the notifier is recorded, not run.
|
||||||
const replayTargetURL = "http://93.184.216.34/hook"
|
const replayTargetURL = "http://93.184.216.34/hook"
|
||||||
|
|
||||||
|
// replayEventQuery is the query string a seeded event arrived with.
|
||||||
|
const replayEventQuery = "a=1&b=2"
|
||||||
|
|
||||||
// seedFailedDelivery records an event, a terminally failed delivery of
|
// seedFailedDelivery records an event, a terminally failed delivery of
|
||||||
// it to the given target, and the attempt that failed.
|
// it to the given target, and the attempt that failed.
|
||||||
func seedFailedDelivery(
|
func seedFailedDelivery(
|
||||||
@@ -40,6 +45,7 @@ func seedFailedDelivery(
|
|||||||
WebhookID: webhookID,
|
WebhookID: webhookID,
|
||||||
EntrypointID: "entrypoint-" + webhookID,
|
EntrypointID: "entrypoint-" + webhookID,
|
||||||
Method: http.MethodPost,
|
Method: http.MethodPost,
|
||||||
|
RawQuery: replayEventQuery,
|
||||||
Headers: `{"X-Test":["yes"]}`,
|
Headers: `{"X-Test":["yes"]}`,
|
||||||
Body: `{"replay":"me"}`,
|
Body: `{"replay":"me"}`,
|
||||||
ContentType: contentTypeJSON,
|
ContentType: contentTypeJSON,
|
||||||
@@ -294,6 +300,10 @@ func assertReplayTask(
|
|||||||
"replay must use the target's current configuration",
|
"replay must use the target's current configuration",
|
||||||
)
|
)
|
||||||
assert.Equal(t, event.Method, task.Method)
|
assert.Equal(t, event.Method, task.Method)
|
||||||
|
assert.Equal(
|
||||||
|
t, replayEventQuery, task.RawQuery,
|
||||||
|
"replay re-sends the stored query string",
|
||||||
|
)
|
||||||
assert.Equal(t, event.Headers, task.Headers)
|
assert.Equal(t, event.Headers, task.Headers)
|
||||||
assert.Equal(t, event.ContentType, task.ContentType)
|
assert.Equal(t, event.ContentType, task.ContentType)
|
||||||
assert.Equal(t, 1, task.AttemptNum)
|
assert.Equal(t, 1, task.AttemptNum)
|
||||||
@@ -470,6 +480,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
|
||||||
|
// Replay pressed in the Failed list carries that list in its form and
|
||||||
|
// returns to it, and that a show value the event log does not know
|
||||||
|
// returns to the full log.
|
||||||
|
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, renderSourceLogsPageWithQuery(
|
||||||
|
t, h, sess, wh.ID, "?show=failed",
|
||||||
|
), `name="show" value="failed"`)
|
||||||
|
|
||||||
|
// The second replay is refused, as the first is still queued.
|
||||||
|
for _, tc := range []struct{ show, location string }{
|
||||||
|
{"failed", "/hook/" + wh.ID +
|
||||||
|
"/events?notice=replay-queued&show=failed"},
|
||||||
|
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
|
||||||
|
} {
|
||||||
|
req := postRequest(
|
||||||
|
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
|
||||||
|
authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
),
|
||||||
|
map[string]string{
|
||||||
|
paramSourceID: wh.ID,
|
||||||
|
paramDeliveryID: original.ID,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleDeliveryReplay().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
|
||||||
|
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
|
||||||
// reaches the page it belongs on: a finished delivery renders a POST
|
// reaches the page it belongs on: a finished delivery renders a POST
|
||||||
// form carrying a CSRF token, and the outcome code a refusal redirects
|
// form carrying a CSRF token, and the outcome code a refusal redirects
|
||||||
@@ -513,7 +583,11 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert.Contains(t, refused, "alert-error")
|
assert.Contains(t, refused, "alert-error")
|
||||||
assert.Contains(t, refused, "has been deleted")
|
assert.Contains(
|
||||||
|
t, refused,
|
||||||
|
"has been deleted. Use Resubmit to send the event "+
|
||||||
|
"to the webhook",
|
||||||
|
)
|
||||||
|
|
||||||
// An outcome code nobody issued renders no banner at all.
|
// An outcome code nobody issued renders no banner at all.
|
||||||
unknown := renderSourceLogsPageWithQuery(
|
unknown := renderSourceLogsPageWithQuery(
|
||||||
@@ -524,3 +598,58 @@ func TestHandleSourceLogs_RendersReplayControlAndBanner(t *testing.T) {
|
|||||||
assert.NotContains(t, unknown, "alert-success")
|
assert.NotContains(t, unknown, "alert-success")
|
||||||
assert.NotContains(t, unknown, "made-up")
|
assert.NotContains(t, unknown, "made-up")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleDeliveryReplay_LabelsTheReplay proves a delivery created
|
||||||
|
// by Replay is labelled as a replay in the event's summary line in the
|
||||||
|
// event log, and in the list of the event's deliveries there and on
|
||||||
|
// the event's page, while the delivery it repeats is not.
|
||||||
|
func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
tgt := seedConfiguredTarget(
|
||||||
|
t, db, wh.ID, database.TargetTypeHTTP,
|
||||||
|
`{"url":"`+replayTargetURL+`"}`,
|
||||||
|
)
|
||||||
|
|
||||||
|
event, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
|
||||||
|
|
||||||
|
w := postReplay(t, h, sess, wh.ID, original.ID)
|
||||||
|
require.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
|
||||||
|
eventLog := renderSourceLogsPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Contains(t, eventLog, tgt.Name+": failed")
|
||||||
|
assert.Contains(t, eventLog, tgt.Name+" (replay): pending")
|
||||||
|
|
||||||
|
w = serveEventPage(t, h, sess, wh.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
// In each delivery list a row names the target, then the label if
|
||||||
|
// it is a replay, then its status: the replay is still pending, the
|
||||||
|
// original failed.
|
||||||
|
replayRow := tgt.Name + `</span> ` +
|
||||||
|
`<span class="text-xs text-gray-500">replay</span> ` +
|
||||||
|
`<span class="text-xs text-gray-400">pending</span>`
|
||||||
|
originalRow := tgt.Name + `</span> ` +
|
||||||
|
`<span class="text-xs text-red-600">failed</span>`
|
||||||
|
|
||||||
|
for _, page := range []string{eventLog, w.Body.String()} {
|
||||||
|
page = strings.Join(strings.Fields(page), " ")
|
||||||
|
|
||||||
|
assert.Contains(t, page, replayRow)
|
||||||
|
assert.Contains(t, page, originalRow)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,8 +27,8 @@ const maxRenderedResponseBytes = 4096
|
|||||||
// bytes rather than characters, and they make SQLite do the
|
// bytes rather than characters, and they make SQLite do the
|
||||||
// cut, so an oversized stored response never becomes a Go
|
// cut, so an oversized stored response never becomes a Go
|
||||||
// string at all.
|
// string at all.
|
||||||
const deliveryResultColumns = "delivery_id, attempt_num, success, " +
|
const deliveryResultColumns = "delivery_id, attempt_num, created_at, " +
|
||||||
"status_code, error, duration, " +
|
"success, status_code, error, duration, " +
|
||||||
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
"substr(cast(response_body as blob), 1, ?) AS response_body, " +
|
||||||
"length(cast(response_body as blob)) AS response_bytes"
|
"length(cast(response_body as blob)) AS response_bytes"
|
||||||
|
|
||||||
@@ -45,6 +48,11 @@ type DeliveryResultView struct {
|
|||||||
AttemptNum int
|
AttemptNum int
|
||||||
Success bool
|
Success bool
|
||||||
|
|
||||||
|
// Ran is how long ago the attempt was recorded, and RanUTC the
|
||||||
|
// full timestamp the page shows on hover.
|
||||||
|
Ran string
|
||||||
|
RanUTC string
|
||||||
|
|
||||||
// StatusCode is 0 when the attempt never got a response,
|
// StatusCode is 0 when the attempt never got a response,
|
||||||
// which is why the page asks HasStatusCode rather than
|
// which is why the page asks HasStatusCode rather than
|
||||||
// printing the number.
|
// printing the number.
|
||||||
@@ -100,6 +108,7 @@ func (v DeliveryResultView) HasStatusCode() bool {
|
|||||||
type deliveryResultRow struct {
|
type deliveryResultRow struct {
|
||||||
DeliveryID string
|
DeliveryID string
|
||||||
AttemptNum int
|
AttemptNum int
|
||||||
|
CreatedAt time.Time
|
||||||
Success bool
|
Success bool
|
||||||
StatusCode int
|
StatusCode int
|
||||||
Error string
|
Error string
|
||||||
@@ -157,6 +166,8 @@ func (r *deliveryResultRow) view(
|
|||||||
return DeliveryResultView{
|
return DeliveryResultView{
|
||||||
AttemptNum: r.AttemptNum,
|
AttemptNum: r.AttemptNum,
|
||||||
Success: r.Success,
|
Success: r.Success,
|
||||||
|
Ran: humanize.Time(r.CreatedAt),
|
||||||
|
RanUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
||||||
StatusCode: r.StatusCode,
|
StatusCode: r.StatusCode,
|
||||||
Error: redactor.Redact(r.Error),
|
Error: redactor.Redact(r.Error),
|
||||||
DurationMS: r.Duration,
|
DurationMS: r.Duration,
|
||||||
|
|||||||
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
|
|||||||
}).Error)
|
}).Error)
|
||||||
}
|
}
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
|
|
||||||
@@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
|
|||||||
stored := strings.Repeat("A", responseCap*4) + tail
|
stored := strings.Repeat("A", responseCap*4) + tail
|
||||||
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
|
||||||
|
|
||||||
views := h.LoadEventLogViewsForTest(
|
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
|
||||||
httptest.NewRecorder(), *wh, 1,
|
|
||||||
)
|
|
||||||
require.Len(t, views, 1)
|
require.Len(t, views, 1)
|
||||||
require.Len(t, views[0].Deliveries, 1)
|
require.Len(t, views[0].Deliveries, 1)
|
||||||
require.Len(t, views[0].Deliveries[0].Results, 1)
|
require.Len(t, views[0].Deliveries[0].Results, 1)
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleEntrypointCreate handles adding a new entrypoint.
|
||||||
|
func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
description := r.PostFormValue("description")
|
||||||
|
|
||||||
|
entrypoint := &database.Entrypoint{
|
||||||
|
WebhookID: webhook.ID,
|
||||||
|
Path: uuid.New().String(),
|
||||||
|
Description: description,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.db.DB().Create(entrypoint).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to create entrypoint", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, entrypointAdded),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointEdit handles changing an entrypoint's description.
|
||||||
|
// It writes only the description column, so the entrypoint keeps its
|
||||||
|
// URL, and an activate or deactivate saved since the page was shown
|
||||||
|
// is not undone.
|
||||||
|
func (h *Handlers) HandleEntrypointEdit() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
userID, ok := h.getUserID(r)
|
||||||
|
if !ok {
|
||||||
|
http.Redirect(
|
||||||
|
w, r, "/pages/login", http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sourceID := chi.URLParam(r, "sourceID")
|
||||||
|
entrypointID := chi.URLParam(r, "entrypointID")
|
||||||
|
|
||||||
|
var webhook database.Webhook
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND user_id = ?", sourceID, userID,
|
||||||
|
).First(&webhook).Error
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The body size cap is enforced by the MaxBodySize
|
||||||
|
// middleware, which runs before CSRF parses the form.
|
||||||
|
err = r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.renderError(w, r, http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result := h.db.DB().Model(&database.Entrypoint{}).Where(
|
||||||
|
"id = ? AND webhook_id = ?", entrypointID, webhook.ID,
|
||||||
|
).Update("description", r.PostFormValue("description"))
|
||||||
|
if result.Error != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to edit entrypoint", result.Error,
|
||||||
|
)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The id came from the URL and may name another webhook's
|
||||||
|
// entrypoint, which this webhook does not have.
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Redirect(
|
||||||
|
w, r, withNotice("/hook/"+webhook.ID, entrypointSaved),
|
||||||
|
http.StatusSeeOther,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointDelete handles deleting an entrypoint.
|
||||||
|
func (h *Handlers) HandleEntrypointDelete() http.HandlerFunc {
|
||||||
|
return h.deleteChildResource(
|
||||||
|
"entrypointID", &database.Entrypoint{},
|
||||||
|
"failed to delete entrypoint",
|
||||||
|
nil,
|
||||||
|
entrypointDeleted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleEntrypointToggle handles toggling an entrypoint's
|
||||||
|
// active state.
|
||||||
|
func (h *Handlers) HandleEntrypointToggle() http.HandlerFunc {
|
||||||
|
return h.toggleChildResource(
|
||||||
|
"entrypointID",
|
||||||
|
func(webhookID, childID string) (bool, error) {
|
||||||
|
var ep database.Entrypoint
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"id = ? AND webhook_id = ?",
|
||||||
|
childID, webhookID,
|
||||||
|
).First(&ep).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the active column: saving the whole row would
|
||||||
|
// write back the description read above over an edit
|
||||||
|
// saved since.
|
||||||
|
active := !ep.Active
|
||||||
|
|
||||||
|
return active, h.db.DB().Model(&ep).
|
||||||
|
Update("active", active).Error
|
||||||
|
},
|
||||||
|
"failed to toggle entrypoint",
|
||||||
|
entrypointActivated, entrypointDeactivated,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestHandleEntrypointToggle_DoesNotUndoAnEdit proves that a toggle
|
||||||
|
// which loaded the entrypoint before an edit of its description was
|
||||||
|
// saved does not write the old description back over the edit. The
|
||||||
|
// edit is submitted from a callback on the toggle's own read of the
|
||||||
|
// entrypoint, so it is saved after that read and before the toggle
|
||||||
|
// writes.
|
||||||
|
func TestHandleEntrypointToggle_DoesNotUndoAnEdit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
wh := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
ep := seedEntrypoint(t, env.db, wh.ID)
|
||||||
|
require.True(t, ep.Active)
|
||||||
|
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Post(
|
||||||
|
"/hook/{sourceID}/entrypoints/{entrypointID}/edit",
|
||||||
|
env.handlers.HandleEntrypointEdit(),
|
||||||
|
)
|
||||||
|
router.Post(
|
||||||
|
"/hook/{sourceID}/entrypoints/{entrypointID}/toggle",
|
||||||
|
env.handlers.HandleEntrypointToggle(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// post submits one of the entrypoint's forms as the test user and
|
||||||
|
// returns the response's status code.
|
||||||
|
post := func(action string, form url.Values) int {
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodPost,
|
||||||
|
"/hook/"+wh.ID+"/entrypoints/"+ep.ID+"/"+action,
|
||||||
|
strings.NewReader(form.Encode()),
|
||||||
|
)
|
||||||
|
req.Header.Set(
|
||||||
|
"Content-Type", "application/x-www-form-urlencoded",
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, c := range env.cookies {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
edited bool
|
||||||
|
editCode int
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, env.db.DB().Callback().Query().
|
||||||
|
After("gorm:query").
|
||||||
|
Register("test:edit_after_toggle_read", func(tx *gorm.DB) {
|
||||||
|
// Only the first read of an entrypoint, the toggle's,
|
||||||
|
// submits the edit.
|
||||||
|
if tx.Statement.Table != "entrypoints" || edited {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
edited = true
|
||||||
|
editCode = post(
|
||||||
|
"edit", url.Values{"description": {"Billing sender"}},
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther, post("toggle", nil))
|
||||||
|
require.Equal(t, http.StatusSeeOther, editCode)
|
||||||
|
|
||||||
|
var stored database.Entrypoint
|
||||||
|
|
||||||
|
require.NoError(
|
||||||
|
t, env.db.DB().First(&stored, "id = ?", ep.ID).Error,
|
||||||
|
)
|
||||||
|
assert.False(t, stored.Active)
|
||||||
|
assert.Equal(t, "Billing sender", stored.Description)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"gorm.io/gorm"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,6 +16,21 @@ type EntrypointView struct {
|
|||||||
Path string
|
Path string
|
||||||
Description string
|
Description string
|
||||||
Active bool
|
Active bool
|
||||||
|
|
||||||
|
// Events is how many events arrived on the entrypoint's URL within
|
||||||
|
// the webhook's retention period. LastEvent is when the newest
|
||||||
|
// event ever to arrive on it did, relative, and LastEventUTC the
|
||||||
|
// full time; both are empty when none ever did.
|
||||||
|
Events int64
|
||||||
|
LastEvent string
|
||||||
|
LastEventUTC string
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypointEvents is one entrypoint's count read by
|
||||||
|
// addEntrypointEvents.
|
||||||
|
type entrypointEvents struct {
|
||||||
|
EntrypointID string
|
||||||
|
Events int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewEntrypointViews projects entrypoints for rendering.
|
// NewEntrypointViews projects entrypoints for rendering.
|
||||||
@@ -32,3 +52,60 @@ func NewEntrypointViews(
|
|||||||
|
|
||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// addEntrypointEvents fills in each view's event figures from the
|
||||||
|
// webhook's event database: when the last event arrived on its URL,
|
||||||
|
// from its EntrypointTotals row, and how many events arrived on it
|
||||||
|
// since the webhook's retention cutoff, counted in one query over the
|
||||||
|
// events' entrypoint_id index. Resubmitted copies did not arrive on
|
||||||
|
// the URL and are left out of both.
|
||||||
|
func addEntrypointEvents(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhook *database.Webhook,
|
||||||
|
views []EntrypointView,
|
||||||
|
now time.Time,
|
||||||
|
) error {
|
||||||
|
ids := make([]string, len(views))
|
||||||
|
byID := make(map[string]*EntrypointView, len(views))
|
||||||
|
|
||||||
|
for i := range views {
|
||||||
|
ids[i] = views[i].ID
|
||||||
|
byID[views[i].ID] = &views[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var totals []database.EntrypointTotals
|
||||||
|
|
||||||
|
err := webhookDB.Where("entrypoint_id IN ?", ids).Find(&totals).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading entrypoint totals: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := webhookDB.Model(&database.Event{}).
|
||||||
|
Select("entrypoint_id, count(*) AS events").
|
||||||
|
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL", ids)
|
||||||
|
|
||||||
|
cutoff, finite := webhook.RetentionCutoff(now)
|
||||||
|
if finite {
|
||||||
|
query = query.Where("created_at >= ?", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
|
var counts []entrypointEvents
|
||||||
|
|
||||||
|
err = query.Group("entrypoint_id").Find(&counts).Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("counting events by entrypoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range totals {
|
||||||
|
view := byID[row.EntrypointID]
|
||||||
|
view.LastEvent = humanize.Time(row.LastEventAt)
|
||||||
|
view.LastEventUTC =
|
||||||
|
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range counts {
|
||||||
|
byID[row.EntrypointID].Events = row.Events
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// entrypointRow returns the part of a rendered webhook page from an
|
||||||
|
// entrypoint's URL to the next entrypoint's, which holds its figures.
|
||||||
|
func entrypointRow(t *testing.T, page, entrypointID string) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, row, found := strings.Cut(page, `id="entrypoint-url-`+entrypointID+`"`)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
|
||||||
|
|
||||||
|
return row
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastEventShown matches an entrypoint row's last event arriving at at.
|
||||||
|
func lastEventShown(at time.Time) string {
|
||||||
|
return `Last Event:</span>\s*<span title="` +
|
||||||
|
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsShown matches an entrypoint row's count of n events.
|
||||||
|
func eventsShown(n int) string {
|
||||||
|
return `Events Within Retention:</span>\s*<span>` +
|
||||||
|
strconv.Itoa(n) + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
|
||||||
|
// on the webhook page shows its own figures: how many events arrived
|
||||||
|
// through it within the webhook's retention period, leaving out one
|
||||||
|
// older than that, and when the newest arrived, or "never" for an
|
||||||
|
// entrypoint with none.
|
||||||
|
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
webhookDB, err := dbMgr.GetDB(wh.ID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
entrypoint := func() *database.Entrypoint {
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
|
||||||
|
}
|
||||||
|
require.NoError(t,
|
||||||
|
db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
// event stores an event that arrived on ep's URL age ago and
|
||||||
|
// records it as ep's last event, as the receiver does.
|
||||||
|
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
|
||||||
|
e := &database.Event{
|
||||||
|
WebhookID: wh.ID,
|
||||||
|
EntrypointID: ep.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
}
|
||||||
|
e.CreatedAt = time.Now().Add(-age)
|
||||||
|
require.NoError(t,
|
||||||
|
webhookDB.Omit(clause.Associations).Create(e).Error)
|
||||||
|
require.NoError(t, database.AddEntrypointTotals(webhookDB,
|
||||||
|
database.EntrypointTotals{
|
||||||
|
EntrypointID: ep.ID, LastEventAt: e.CreatedAt,
|
||||||
|
}))
|
||||||
|
|
||||||
|
return e.CreatedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
|
||||||
|
|
||||||
|
event(busy, 8*24*time.Hour) // older than the 7 days kept
|
||||||
|
event(busy, 3*time.Hour)
|
||||||
|
busyLast := event(busy, time.Hour)
|
||||||
|
quietLast := event(quiet, 2*24*time.Hour)
|
||||||
|
|
||||||
|
body := renderSourceDetailPage(t, h, sess, wh.ID)
|
||||||
|
|
||||||
|
assert.Regexp(t, lastEventShown(busyLast), entrypointRow(t, body, busy.ID))
|
||||||
|
assert.Regexp(t, eventsShown(2), entrypointRow(t, body, busy.ID))
|
||||||
|
assert.Regexp(t, lastEventShown(quietLast), entrypointRow(t, body, quiet.ID))
|
||||||
|
assert.Regexp(t, eventsShown(1), entrypointRow(t, body, quiet.ID))
|
||||||
|
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`,
|
||||||
|
entrypointRow(t, body, unused.ID))
|
||||||
|
assert.Regexp(t, eventsShown(0), entrypointRow(t, body, unused.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_EntrypointLastEventSurvivesRetention checks
|
||||||
|
// that once retention has removed every event that arrived on an
|
||||||
|
// entrypoint's URL, the entrypoint still shows when the last one
|
||||||
|
// arrived rather than "never".
|
||||||
|
func TestHandleSourceDetail_EntrypointLastEventSurvivesRetention(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
log *logger.Logger
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := &database.Webhook{
|
||||||
|
UserID: deleteTestUserID, Name: "swept", RetentionDays: 1,
|
||||||
|
}
|
||||||
|
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
||||||
|
|
||||||
|
ep := seedEntrypoint(t, db, wh.ID)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
||||||
|
arrived := events[0].CreatedAt
|
||||||
|
|
||||||
|
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
||||||
|
statsPrune(t, db, dbMgr, log, webhookDB)
|
||||||
|
require.Empty(t, listEvents(t, webhookDB))
|
||||||
|
|
||||||
|
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
||||||
|
assert.Regexp(t, lastEventShown(arrived), row)
|
||||||
|
assert.Regexp(t, eventsShown(0), row)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleSourceDetail_ResubmitLeavesEntrypointFigures checks that a
|
||||||
|
// resubmitted copy, which did not arrive on the entrypoint's URL,
|
||||||
|
// changes neither the entrypoint's last event nor its count.
|
||||||
|
func TestHandleSourceDetail_ResubmitLeavesEntrypointFigures(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
wh := seedWebhook(t, db)
|
||||||
|
ep := seedEntrypoint(t, db, wh.ID)
|
||||||
|
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, ep.Path, 1)
|
||||||
|
arrived := events[0].CreatedAt
|
||||||
|
|
||||||
|
require.Equal(t, http.StatusSeeOther,
|
||||||
|
postResubmit(t, h, sess, wh.ID, events[0].ID).Code)
|
||||||
|
require.Len(t, listEvents(t, webhookDB), 2)
|
||||||
|
|
||||||
|
var totals database.EntrypointTotals
|
||||||
|
|
||||||
|
require.NoError(t, webhookDB.Take(&totals).Error)
|
||||||
|
assert.True(t, arrived.Equal(totals.LastEventAt))
|
||||||
|
|
||||||
|
row := entrypointRow(t, renderSourceDetailPage(t, h, sess, wh.ID), ep.ID)
|
||||||
|
assert.Regexp(t, lastEventShown(arrived), row)
|
||||||
|
assert.Regexp(t, eventsShown(1), row)
|
||||||
|
}
|
||||||
@@ -15,16 +15,19 @@ import (
|
|||||||
// eventBodyQuery reads one event's stored body as bytes. The cast
|
// eventBodyQuery reads one event's stored body as bytes. The cast
|
||||||
// to blob is what makes the driver hand back the stored bytes
|
// to blob is what makes the driver hand back the stored bytes
|
||||||
// rather than a string conversion, so Content-Length taken from
|
// rather than a string conversion, so Content-Length taken from
|
||||||
// the result matches what goes on the wire. The soft-delete
|
// the result matches what goes on the wire. The retention reaper
|
||||||
// predicate is spelled out because Raw bypasses GORM's default
|
// deletes event rows outright, so a reaped event is simply gone
|
||||||
// scope, and it is what stops a reaped event still being
|
// and the query finds no row. The deleted_at predicate repeats
|
||||||
// downloadable.
|
// the soft-delete scope GORM adds to its own queries, which Raw
|
||||||
|
// bypasses; nothing soft-deletes an event, so today it excludes
|
||||||
|
// nothing.
|
||||||
const eventBodyQuery = "SELECT cast(body as blob) " +
|
const eventBodyQuery = "SELECT cast(body as blob) " +
|
||||||
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
|
||||||
|
|
||||||
// HandleEventBodyDownload serves one event's stored body in
|
// HandleEventBodyDownload serves one event's stored body byte
|
||||||
// full, which the event log page cannot: it caps each rendered
|
// for byte, which the pages do not: they show it as escaped
|
||||||
// body at maxRenderedBodyBytes.
|
// text, cut at maxRenderedBodyBytes in the lists of events, and
|
||||||
|
// leave a binary one out.
|
||||||
//
|
//
|
||||||
// The bytes are attacker-supplied — anyone who can reach the
|
// The bytes are attacker-supplied — anyone who can reach the
|
||||||
// public receiver chooses them — and this route hands them back
|
// public receiver chooses them — and this route hands them back
|
||||||
|
|||||||
@@ -405,10 +405,11 @@ func TestHandleEventBodyDownload_UnknownEvent404s(t *testing.T) {
|
|||||||
// route. The body is read in one query before any header is
|
// route. The body is read in one query before any header is
|
||||||
// written, so a reaped event cannot produce a partial download:
|
// written, so a reaped event cannot produce a partial download:
|
||||||
// it is a clean 404 with no Content-Length and no
|
// it is a clean 404 with no Content-Length and no
|
||||||
// Content-Disposition. Both removals the codebase performs are
|
// Content-Disposition. The reaper deletes event rows outright,
|
||||||
// covered — the reaper hard-deletes, and a soft-deleted row is
|
// which is the "hard deleted" case. The "soft deleted" case
|
||||||
// excluded by the query's own deleted_at predicate rather than
|
// covers a row no code produces today: it only pins the query's
|
||||||
// by GORM's default scope, which Raw bypasses.
|
// own deleted_at predicate, the soft-delete condition Raw would
|
||||||
|
// otherwise skip.
|
||||||
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
func TestHandleEventBodyDownload_ReapedEvent404s(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxRenderedBodyBytes is the most of one event's body that the
|
||||||
|
// recent events on a webhook's page and the event log show; a larger
|
||||||
|
// body is cut there and shown whole only on the event's own page.
|
||||||
|
// Bodies come from the unauthenticated receiver under its 1 MB cap,
|
||||||
|
// and renderTemplate buffers a whole page before writing it, so a list
|
||||||
|
// of events cannot show every body whole.
|
||||||
|
const maxRenderedBodyBytes = 32 << 10
|
||||||
|
|
||||||
|
// maxInlineBodyLines is the most lines a body is shown at its full
|
||||||
|
// height with. A body with more lines, or larger than
|
||||||
|
// maxRenderedBodyBytes, is shown in a box of fixed height that
|
||||||
|
// scrolls, so that it does not make the page huge.
|
||||||
|
const maxInlineBodyLines = 200
|
||||||
|
|
||||||
|
// maxIndentDepth is how deeply a JSON body's objects and arrays may
|
||||||
|
// nest for it to be indented at all; a deeper one is shown as received.
|
||||||
|
// Each level indents every line inside it two more spaces, so 10 KB of
|
||||||
|
// nested brackets would indent to some 50 MB; within this depth a body
|
||||||
|
// grows at most 35 times.
|
||||||
|
const maxIndentDepth = 16
|
||||||
|
|
||||||
|
// A JSON body is shown pretty-printed only when that makes it at most
|
||||||
|
// maxIndentGrowth times its size plus indentAllowance bytes, and
|
||||||
|
// otherwise as received, so that indenting does not undo
|
||||||
|
// maxRenderedBodyBytes. The allowance keeps a small nested body
|
||||||
|
// pretty-printed.
|
||||||
|
const (
|
||||||
|
maxIndentGrowth = 4
|
||||||
|
indentAllowance = 1 << 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// jsonIndent is the indent of a pretty-printed JSON body.
|
||||||
|
const jsonIndent = " "
|
||||||
|
|
||||||
|
// BodyView is an event's body as the pages show it. newBodyView
|
||||||
|
// decides it and templates/event_body.html shows it, the same way in
|
||||||
|
// the recent events on a webhook's page, in the event log and on the
|
||||||
|
// event's own page.
|
||||||
|
type BodyView struct {
|
||||||
|
// EventURL is the event's own page. The stored body downloads
|
||||||
|
// from EventURL/body.
|
||||||
|
EventURL string
|
||||||
|
|
||||||
|
// Text is the body as shown, pretty-printed when it is JSON.
|
||||||
|
Text string
|
||||||
|
|
||||||
|
// Size is the stored body's size in bytes, and ShownBytes how
|
||||||
|
// many of them Text holds when Cut.
|
||||||
|
Size int64
|
||||||
|
ShownBytes int
|
||||||
|
|
||||||
|
// Cut reports that Text is only the start of the body.
|
||||||
|
Cut bool
|
||||||
|
|
||||||
|
// Binary reports a body that is not text. It is not shown.
|
||||||
|
Binary bool
|
||||||
|
|
||||||
|
// Scroll reports a body to show in a box that scrolls.
|
||||||
|
Scroll bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBodyView decides how to show an event's body. body is the
|
||||||
|
// stored body, or its first maxRenderedBodyBytes when only those were
|
||||||
|
// read, and size is the stored body's size.
|
||||||
|
func newBodyView(eventURL string, body []byte, size int64) BodyView {
|
||||||
|
v := BodyView{EventURL: eventURL, Size: size}
|
||||||
|
|
||||||
|
if size > int64(len(body)) {
|
||||||
|
v.Cut = true
|
||||||
|
body = trimPartialRune(body)
|
||||||
|
v.ShownBytes = len(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// html/template shows invalid UTF-8 as replacement characters,
|
||||||
|
// and a browser shows a control character other than tab, line
|
||||||
|
// feed and carriage return as a box or not at all, so a body
|
||||||
|
// holding either is not text.
|
||||||
|
isControl := func(r rune) bool {
|
||||||
|
return unicode.IsControl(r) && r != '\t' && r != '\n' && r != '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utf8.Valid(body) || bytes.IndexFunc(body, isControl) >= 0 {
|
||||||
|
v.Binary = true
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cut JSON document is no longer valid JSON.
|
||||||
|
if !v.Cut {
|
||||||
|
body = indentJSON(body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page shows a carriage return, a line feed, or the two
|
||||||
|
// together as one line break. A final one ends the last line
|
||||||
|
// rather than starting another.
|
||||||
|
text := bytes.TrimSuffix(body, []byte("\n"))
|
||||||
|
text = bytes.TrimSuffix(text, []byte("\r"))
|
||||||
|
breaks := bytes.Count(text, []byte("\n")) + bytes.Count(text, []byte("\r")) -
|
||||||
|
bytes.Count(text, []byte("\r\n"))
|
||||||
|
lines := breaks + 1
|
||||||
|
|
||||||
|
v.Text = string(body)
|
||||||
|
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// indentJSON returns body pretty-printed when it is a JSON document,
|
||||||
|
// and unchanged when it is not, nests deeper than maxIndentDepth, or
|
||||||
|
// would grow past maxIndentGrowth times its size plus indentAllowance
|
||||||
|
// bytes.
|
||||||
|
func indentJSON(body []byte) []byte {
|
||||||
|
if !json.Valid(body) || !indentFits(body) {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
var out bytes.Buffer
|
||||||
|
|
||||||
|
err := json.Indent(&out, body, "", jsonIndent)
|
||||||
|
if err != nil || out.Len() > maxIndentGrowth*len(body)+indentAllowance {
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
return out.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// indentFits reports whether the objects and arrays of the JSON
|
||||||
|
// document body nest at most maxIndentDepth deep.
|
||||||
|
func indentFits(body []byte) bool {
|
||||||
|
depth := 0
|
||||||
|
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
|
||||||
|
// A number too large for a float64 is still valid JSON.
|
||||||
|
dec.UseNumber()
|
||||||
|
|
||||||
|
for {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch tok {
|
||||||
|
case json.Delim('{'), json.Delim('['):
|
||||||
|
depth++
|
||||||
|
if depth > maxIndentDepth {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
case json.Delim('}'), json.Delim(']'):
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bodyView is how the pages would show body, stored whole.
|
||||||
|
func bodyView(body string) handlers.BodyView {
|
||||||
|
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// lines is n lines of text, without a newline after the last.
|
||||||
|
func lines(n int) string {
|
||||||
|
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
|
||||||
|
// pretty-printed, whatever its content type, with its keys in
|
||||||
|
// the order they arrived.
|
||||||
|
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
`{`,
|
||||||
|
` "b": 1,`,
|
||||||
|
` "a": [`,
|
||||||
|
` true,`,
|
||||||
|
` null,`,
|
||||||
|
` "x"`,
|
||||||
|
` ],`,
|
||||||
|
` "c": {}`,
|
||||||
|
`}`,
|
||||||
|
}, strings.Split(v.Text, "\n"))
|
||||||
|
assert.False(t, v.Scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_FormatsNestedJSON proves a small document with a
|
||||||
|
// few levels of nesting is pretty-printed.
|
||||||
|
func TestNewBodyView_FormatsNestedJSON(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
v := bodyView(`{"data":[[1,2,3],[4,5,6]]}`)
|
||||||
|
|
||||||
|
assert.Equal(t, []string{
|
||||||
|
`{`,
|
||||||
|
` "data": [`,
|
||||||
|
` [`,
|
||||||
|
` 1,`,
|
||||||
|
` 2,`,
|
||||||
|
` 3`,
|
||||||
|
` ],`,
|
||||||
|
` [`,
|
||||||
|
` 4,`,
|
||||||
|
` 5,`,
|
||||||
|
` 6`,
|
||||||
|
` ]`,
|
||||||
|
` ]`,
|
||||||
|
`}`,
|
||||||
|
}, strings.Split(v.Text, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
|
||||||
|
// a JSON document is shown exactly as it arrived.
|
||||||
|
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
`{"a":1,`,
|
||||||
|
`{"a":1} {"b":2}`,
|
||||||
|
"plain text\n indented",
|
||||||
|
} {
|
||||||
|
assert.Equal(t, body, bodyView(body).Text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_DeepJSONAsReceived proves a JSON body nested
|
||||||
|
// more than 16 levels deep is shown as it arrived. 10 KB of nested
|
||||||
|
// arrays would indent to some 50 MB.
|
||||||
|
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
nested := func(depth int) string {
|
||||||
|
return strings.Repeat("[", depth) + "1" + strings.Repeat("]", depth)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEqual(t, nested(16), bodyView(nested(16)).Text)
|
||||||
|
assert.Equal(t, nested(17), bodyView(nested(17)).Text)
|
||||||
|
|
||||||
|
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
|
||||||
|
|
||||||
|
assert.Equal(t, body, bodyView(body).Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_GrowingJSONAsReceived proves a JSON body that
|
||||||
|
// pretty-printing would make more than four times its size plus 1 KiB
|
||||||
|
// is shown as it arrived, however shallow: each short element eight
|
||||||
|
// levels deep gets a line indented sixteen spaces.
|
||||||
|
func TestNewBodyView_GrowingJSONAsReceived(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
numbers := func(n int) string {
|
||||||
|
return strings.Repeat("[", 8) +
|
||||||
|
strings.TrimSuffix(strings.Repeat("1,", n), ",") +
|
||||||
|
strings.Repeat("]", 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.NotEqual(t, numbers(10), bodyView(numbers(10)).Text)
|
||||||
|
assert.Equal(t, numbers(1000), bodyView(numbers(1000)).Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
|
||||||
|
// its full height up to 200 lines and in the scrolling box past
|
||||||
|
// them, counting the lines after formatting.
|
||||||
|
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.False(t, bodyView(lines(200)).Scroll)
|
||||||
|
assert.True(t, bodyView(lines(201)).Scroll)
|
||||||
|
|
||||||
|
// A final newline ends the last line rather than starting another.
|
||||||
|
assert.False(t, bodyView(lines(200)+"\n").Scroll)
|
||||||
|
assert.True(t, bodyView(lines(201)+"\n").Scroll)
|
||||||
|
|
||||||
|
// The page shows a carriage return, a line feed, or the two
|
||||||
|
// together as one line break.
|
||||||
|
assert.True(t, bodyView(strings.Repeat("line\r", 400)).Scroll)
|
||||||
|
assert.False(t, bodyView(strings.Repeat("line\r\n", 200)).Scroll)
|
||||||
|
|
||||||
|
// One line as received, 201 once formatted: the brackets and
|
||||||
|
// 199 elements.
|
||||||
|
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
|
||||||
|
|
||||||
|
assert.NotContains(t, numbers, "\n")
|
||||||
|
assert.True(t, bodyView(numbers).Scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
|
||||||
|
// cap of the lists of events is shown in the scrolling box
|
||||||
|
// however few lines it has, on the event's own page as in the
|
||||||
|
// lists.
|
||||||
|
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
|
||||||
|
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewBodyView_BinaryNotShown proves a body that is not text
|
||||||
|
// is never shown: one that is not valid UTF-8, or that holds a
|
||||||
|
// control character other than tab, line feed and carriage return.
|
||||||
|
func TestNewBodyView_BinaryNotShown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, body := range []string{
|
||||||
|
"\xff\xfe\xfd",
|
||||||
|
"a\x00b",
|
||||||
|
// A small protobuf message: valid UTF-8, but control bytes.
|
||||||
|
"\x08\x01\x12\x03abc",
|
||||||
|
"\x1b[31mred\x1b[0m",
|
||||||
|
"a\x7fb",
|
||||||
|
} {
|
||||||
|
v := bodyView(body)
|
||||||
|
|
||||||
|
assert.True(t, v.Binary, "%q", body)
|
||||||
|
assert.Empty(t, v.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, bodyView("snow "+snowman).Binary)
|
||||||
|
assert.False(t, bodyView("a\tb\r\nc\n").Binary)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleEventDetail shows one event on its own page: its details,
|
||||||
|
// its whole body and every delivery of it. The page reads the
|
||||||
|
// event's body whole, which the receiver caps at 1 MB.
|
||||||
|
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to get webhook database", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []eventLogRow
|
||||||
|
|
||||||
|
err = webhookDB.Model(&database.Event{}).
|
||||||
|
Select(eventColumns).
|
||||||
|
Where(
|
||||||
|
"id = ? AND webhook_id = ?",
|
||||||
|
chi.URLParam(r, "eventID"), webhook.ID,
|
||||||
|
).
|
||||||
|
Limit(1).
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load event", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
h.renderError(w, r, http.StatusNotFound)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := h.loadTargetMap(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load targets", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The page shows every request header.
|
||||||
|
views, ok := h.eventLogViews(
|
||||||
|
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, r, "event_detail.html", map[string]any{
|
||||||
|
tmplKeyWebhook: &webhook,
|
||||||
|
"Event": views[0],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// serveEventPage runs the real event page handler as the test user
|
||||||
|
// for the given webhook and event ids.
|
||||||
|
func serveEventPage(
|
||||||
|
t *testing.T,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
sess *session.Session,
|
||||||
|
webhookID, eventID string,
|
||||||
|
) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(),
|
||||||
|
http.MethodGet,
|
||||||
|
"/hook/"+webhookID+"/events/"+eventID,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, c := range authenticatedCookies(
|
||||||
|
t, sess, deleteTestUserID, deleteTestUsername,
|
||||||
|
) {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
rctx := chi.NewRouteContext()
|
||||||
|
rctx.URLParams.Add(paramSourceID, webhookID)
|
||||||
|
rctx.URLParams.Add(paramEventID, eventID)
|
||||||
|
|
||||||
|
req = req.WithContext(
|
||||||
|
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
|
||||||
|
)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleEventDetail().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
|
||||||
|
// event's page shows its details, its whole body even past the cap
|
||||||
|
// of the lists of events, pretty-printed and in the scrolling box,
|
||||||
|
// and each delivery with its status and attempts.
|
||||||
|
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||||
|
|
||||||
|
const sentinel = "TAIL-SENTINEL-5b2e"
|
||||||
|
|
||||||
|
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
|
||||||
|
`","tail":"` + sentinel + `"}`
|
||||||
|
event := f.event(t, contentTypeJSON, body, time.Now())
|
||||||
|
f.attempt(t, f.delivery(
|
||||||
|
t, event, target.ID, database.DeliveryStatusFailed,
|
||||||
|
), http.StatusBadGateway, time.Second)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
|
||||||
|
assert.Contains(t, page, event.ID)
|
||||||
|
assert.Contains(t, page, contentTypeJSON)
|
||||||
|
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
|
||||||
|
assert.Contains(t, page, "{\n "pad": "xxx")
|
||||||
|
assert.Contains(t, page, ""tail": ""+sentinel+""\n}")
|
||||||
|
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
|
||||||
|
assert.NotContains(t, page, "Showing the first")
|
||||||
|
assert.Contains(t, page, target.Name)
|
||||||
|
assert.Contains(t, page, ">failed</span>")
|
||||||
|
assert.Contains(t, page, "Status: 502")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
|
||||||
|
// page links to its original's page, and the original's page says
|
||||||
|
// it was resubmitted.
|
||||||
|
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
original := f.event(t, contentTypeJSON, "{}", time.Now())
|
||||||
|
|
||||||
|
cp := &database.Event{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Body: "{}",
|
||||||
|
ContentType: contentTypeJSON,
|
||||||
|
ResubmittedFromID: &original.ID,
|
||||||
|
}
|
||||||
|
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(
|
||||||
|
t, w.Body.String(),
|
||||||
|
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
|
||||||
|
)
|
||||||
|
|
||||||
|
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "as 1 new event<")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
|
||||||
|
// 404 for an event that does not exist and for one that belongs to
|
||||||
|
// another webhook.
|
||||||
|
func TestHandleEventDetail_UnknownEventNotFound(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var (
|
||||||
|
h *handlers.Handlers
|
||||||
|
sess *session.Session
|
||||||
|
db *database.Database
|
||||||
|
dbMgr *database.WebhookDBManager
|
||||||
|
)
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
mine := seedWebhook(t, db)
|
||||||
|
theirs := seedWebhook(t, db)
|
||||||
|
|
||||||
|
seedEventWithBody(t, dbMgr, mine.ID, "{}")
|
||||||
|
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
|
||||||
|
|
||||||
|
for _, id := range []string{"no-such-event", elsewhere.ID} {
|
||||||
|
w := serveEventPage(t, h, sess, mine.ID, id)
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,621 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeliveryView is the display-safe projection of a delivery
|
||||||
|
// for the event log page. Its target is a TargetView, so the
|
||||||
|
// stored configuration blob — which holds the target's
|
||||||
|
// credential — has no path to the template.
|
||||||
|
type DeliveryView struct {
|
||||||
|
ID string
|
||||||
|
Status database.DeliveryStatus
|
||||||
|
Target delivery.TargetView
|
||||||
|
|
||||||
|
// Replay is set on a delivery the Replay action created.
|
||||||
|
Replay bool
|
||||||
|
|
||||||
|
// Created is how long ago the delivery was created, and
|
||||||
|
// CreatedUTC the full timestamp the page shows on hover.
|
||||||
|
Created string
|
||||||
|
CreatedUTC string
|
||||||
|
|
||||||
|
// Results is this delivery's attempts in attempt order,
|
||||||
|
// bounded by maxRenderedAttempts. Without them a failure
|
||||||
|
// renders as the status word alone and says nothing about
|
||||||
|
// why.
|
||||||
|
Results []DeliveryResultView
|
||||||
|
|
||||||
|
// AttemptCount is how many attempts were recorded, which
|
||||||
|
// is more than len(Results) once the middle was dropped.
|
||||||
|
AttemptCount int
|
||||||
|
|
||||||
|
// AttemptsOmitted is how many attempts were dropped from
|
||||||
|
// the middle of Results. The page must show it, or the
|
||||||
|
// bound would hide history rather than fold it.
|
||||||
|
AttemptsOmitted int
|
||||||
|
|
||||||
|
// Paused is set while the delivery is retrying and its
|
||||||
|
// target's circuit breaker is open, and nil otherwise.
|
||||||
|
Paused *PausedView
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogTarget is what the event log needs to know about
|
||||||
|
// one target: the display-safe view its template renders, and
|
||||||
|
// the redactor that keeps that target's own credential out of
|
||||||
|
// the text its remote peer chose. The two are kept together
|
||||||
|
// so a caller cannot pick up one without the other, and apart
|
||||||
|
// from TargetView so the secrets never reach a template.
|
||||||
|
type eventLogTarget struct {
|
||||||
|
View delivery.TargetView
|
||||||
|
Redactor delivery.Redactor
|
||||||
|
}
|
||||||
|
|
||||||
|
// The event log's show query parameter and its two values: the events
|
||||||
|
// with a failed delivery, and those with a delivery still pending or
|
||||||
|
// retrying.
|
||||||
|
const (
|
||||||
|
showParam = "show"
|
||||||
|
showFailed = "failed"
|
||||||
|
showPending = "pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleSourceLogs shows the request/response logs for a
|
||||||
|
// webhook.
|
||||||
|
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
webhook, ok := h.ownedWebhook(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
targets, err := h.loadTargetMap(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
// Without the map every delivery renders through a
|
||||||
|
// zero redactor, so failing the page is the only
|
||||||
|
// safe answer.
|
||||||
|
h.serverError(w, r, "failed to load targets", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any other value of show lists every event, as no value
|
||||||
|
// does.
|
||||||
|
show := r.URL.Query().Get(showParam)
|
||||||
|
|
||||||
|
statuses := eventLogStatuses(show)
|
||||||
|
if statuses == nil {
|
||||||
|
show = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
evts, total, ok := h.loadEventsWithDeliveries(
|
||||||
|
w, r, webhook, targets, statuses,
|
||||||
|
)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, pending, err := h.countFailedAndPendingEvents(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to count events", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := map[string]any{
|
||||||
|
tmplKeyWebhook: &webhook,
|
||||||
|
"Events": evts,
|
||||||
|
"TotalEvents": total,
|
||||||
|
"Show": show,
|
||||||
|
"FailedEvents": failed,
|
||||||
|
"PendingEvents": pending,
|
||||||
|
}
|
||||||
|
|
||||||
|
h.renderTemplate(w, r, "source_logs.html", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadTargetMap loads targets into a map of display-safe
|
||||||
|
// views keyed by target ID, each paired with its redactor.
|
||||||
|
// The projection happens here so that no caller can hand a
|
||||||
|
// raw target, configuration blob and all, to a template: the
|
||||||
|
// raw rows do not leave this function.
|
||||||
|
//
|
||||||
|
// The load is Unscoped because deleting a target only soft
|
||||||
|
// deletes the row while its deliveries survive in the
|
||||||
|
// per-webhook database. Both halves of the map need those rows:
|
||||||
|
// a scoped load leaves an old delivery with a zero redactor,
|
||||||
|
// which renders its response bodies unredacted, and with a zero
|
||||||
|
// view, which renders its target as a blank name.
|
||||||
|
//
|
||||||
|
// This map is historical display only. It is built for the event
|
||||||
|
// log and an event's own page, and reaches nothing but
|
||||||
|
// DeliveryView.Target: the target list on the source detail page,
|
||||||
|
// the edit form and the replay path each resolve targets
|
||||||
|
// themselves, and a deleted row is refused there as before.
|
||||||
|
func (h *Handlers) loadTargetMap(
|
||||||
|
webhookID string,
|
||||||
|
) (map[string]eventLogTarget, error) {
|
||||||
|
var targets []database.Target
|
||||||
|
|
||||||
|
err := h.db.DB().Unscoped().Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
).Find(&targets).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetMap := make(
|
||||||
|
map[string]eventLogTarget, len(targets),
|
||||||
|
)
|
||||||
|
|
||||||
|
for i := range targets {
|
||||||
|
targetMap[targets[i].ID] = eventLogTarget{
|
||||||
|
Redactor: delivery.NewRedactor(&targets[i]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The views come from NewTargetViews rather than being
|
||||||
|
// rebuilt here, so the masking rules stay in one place and a
|
||||||
|
// deleted target's configuration is masked by the same code
|
||||||
|
// that masks a live one's.
|
||||||
|
for _, v := range delivery.NewTargetViews(targets) {
|
||||||
|
entry := targetMap[v.ID]
|
||||||
|
entry.View = v
|
||||||
|
targetMap[v.ID] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
return targetMap, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEventsWithDeliveries loads the recentEventLimit newest events
|
||||||
|
// and their deliveries from the per-webhook database, and the total
|
||||||
|
// number of events stored. Given delivery statuses, both cover only
|
||||||
|
// the events with a delivery in one of them. Events come back as
|
||||||
|
// capped projections rather than database.Event rows: see
|
||||||
|
// eventLogColumns for why the cut happens in SQL.
|
||||||
|
//
|
||||||
|
// The bool reports whether the load succeeded. It is false
|
||||||
|
// once this has answered the request with an error, and the
|
||||||
|
// caller must then render nothing further.
|
||||||
|
func (h *Handlers) loadEventsWithDeliveries(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhook database.Webhook,
|
||||||
|
targetMap map[string]eventLogTarget,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) ([]EventLogView, int64, bool) {
|
||||||
|
if !h.dbMgr.DBExists(webhook.ID) {
|
||||||
|
return nil, 0, true
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to get webhook database", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, totalEvents, err := loadEventLogRows(
|
||||||
|
webhookDB, webhook.ID, statuses,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load events", err)
|
||||||
|
|
||||||
|
return nil, 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := h.eventLogViews(
|
||||||
|
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||||
|
maxRenderedBodyBytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
return result, totalEvents, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogViews projects loaded events for rendering, each with
|
||||||
|
// its deliveries, how many times it has been resubmitted and the
|
||||||
|
// entrypoint it arrived at (for a resubmitted copy, the one the
|
||||||
|
// request it copies arrived at), and with its request headers only
|
||||||
|
// when their text holds at most maxHeaderBytes. Like
|
||||||
|
// loadEventsWithDeliveries, it reports false once it has answered
|
||||||
|
// the request with an error.
|
||||||
|
func (h *Handlers) eventLogViews(
|
||||||
|
w http.ResponseWriter,
|
||||||
|
r *http.Request,
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
rows []eventLogRow,
|
||||||
|
targetMap map[string]eventLogTarget,
|
||||||
|
maxHeaderBytes int,
|
||||||
|
) ([]EventLogView, bool) {
|
||||||
|
result := make([]EventLogView, len(rows))
|
||||||
|
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||||
|
|
||||||
|
var deliveryIDs []string
|
||||||
|
|
||||||
|
eventIDs := make([]string, len(rows))
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
result[i] = rows[i].view(webhookID, maxHeaderBytes)
|
||||||
|
eventIDs[i] = rows[i].ID
|
||||||
|
|
||||||
|
webhookDB.Where(
|
||||||
|
"event_id = ?", rows[i].ID,
|
||||||
|
).Find(&eventDeliveries[i])
|
||||||
|
|
||||||
|
for j := range eventDeliveries[i] {
|
||||||
|
deliveryIDs = append(
|
||||||
|
deliveryIDs, eventDeliveries[i][j].ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts, err := h.loadDeliveryResults(
|
||||||
|
webhookDB, deliveryIDs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to load delivery attempts", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
resubmits, err := resubmitCounts(webhookDB, eventIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(
|
||||||
|
w, r, "failed to count event resubmissions", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
entrypoints, err := h.entrypointNames(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load entrypoints", err)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
result[i].Deliveries = h.newDeliveryViews(
|
||||||
|
eventDeliveries[i], targetMap, attempts,
|
||||||
|
)
|
||||||
|
result[i].ResubmitCount = resubmits[rows[i].ID]
|
||||||
|
|
||||||
|
name, ok := entrypoints[rows[i].EntrypointID]
|
||||||
|
if !ok {
|
||||||
|
name = "deleted entrypoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i].Entrypoint = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadEventLogRows reads the event log projection of the
|
||||||
|
// recentEventLimit newest events, newest first, and the total number
|
||||||
|
// of events stored, both narrowed by statuses as eventsWithStatus
|
||||||
|
// narrows them.
|
||||||
|
func loadEventLogRows(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) ([]eventLogRow, int64, error) {
|
||||||
|
totalEvents, err := countEventsWithStatus(
|
||||||
|
webhookDB, webhookID, statuses,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []eventLogRow
|
||||||
|
|
||||||
|
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
|
||||||
|
eventLogColumns,
|
||||||
|
maxRenderedBodyBytes, maxRenderedBodyBytes, maxRenderedBodyBytes,
|
||||||
|
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
|
||||||
|
|
||||||
|
return rows, totalEvents, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventLogStatuses returns the delivery statuses the event log's show
|
||||||
|
// value lists events by, or nil for one that lists every event.
|
||||||
|
func eventLogStatuses(show string) []database.DeliveryStatus {
|
||||||
|
switch show {
|
||||||
|
case showFailed:
|
||||||
|
return []database.DeliveryStatus{database.DeliveryStatusFailed}
|
||||||
|
case showPending:
|
||||||
|
return []database.DeliveryStatus{
|
||||||
|
database.DeliveryStatusPending,
|
||||||
|
database.DeliveryStatusRetrying,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventsWithStatus selects the webhook's events, or, given statuses,
|
||||||
|
// the recentEventLimit newest of those with at least one delivery in
|
||||||
|
// one of them.
|
||||||
|
//
|
||||||
|
// Given statuses, its cost follows the matching deliveries. SQLite
|
||||||
|
// never reorders a CROSS JOIN, so it reads each join's left side
|
||||||
|
// first: the distinct event IDs of the matching deliveries, through
|
||||||
|
// idx_deliveries_status; then each of those events by ID, sorted to
|
||||||
|
// keep the newest; then the rows of only the events kept, so no other
|
||||||
|
// event's body is read. With a plain "id IN (matching deliveries)"
|
||||||
|
// condition instead, SQLite, which keeps no statistics on these
|
||||||
|
// tables, walks every event newest first.
|
||||||
|
func eventsWithStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) *gorm.DB {
|
||||||
|
if statuses == nil {
|
||||||
|
return webhookDB.Model(&database.Event{}).Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
matching := webhookDB.Model(&database.Delivery{}).
|
||||||
|
Distinct("event_id").Where("status IN ?", statuses)
|
||||||
|
|
||||||
|
newest := webhookDB.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(recentEventLimit).
|
||||||
|
Select("events.id AS event_id")
|
||||||
|
|
||||||
|
return webhookDB.Table("(?) AS newest", newest).
|
||||||
|
Joins("CROSS JOIN events ON events.id = newest.event_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countEventsWithStatus counts the webhook's events with at least one
|
||||||
|
// delivery in one of the statuses, or every event when statuses is
|
||||||
|
// nil. Given statuses, it counts the distinct events of the matching
|
||||||
|
// deliveries and reads nothing but those deliveries, through
|
||||||
|
// idx_deliveries_status, where counting the events would read every
|
||||||
|
// event row. That is the same number, because retention deletes an
|
||||||
|
// event's deliveries with it.
|
||||||
|
func countEventsWithStatus(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
webhookID string,
|
||||||
|
statuses []database.DeliveryStatus,
|
||||||
|
) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
|
||||||
|
if statuses == nil {
|
||||||
|
err := webhookDB.Model(&database.Event{}).Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
).Count(&count).Error
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.Delivery{}).Distinct("event_id").
|
||||||
|
Where("status IN ?", statuses).Count(&count).Error
|
||||||
|
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// countFailedAndPendingEvents returns how many of the webhook's events
|
||||||
|
// the event log lists when it shows only those with a failed delivery,
|
||||||
|
// and when it shows only those with a delivery pending or retrying.
|
||||||
|
func (h *Handlers) countFailedAndPendingEvents(
|
||||||
|
webhookID string,
|
||||||
|
) (int64, int64, error) {
|
||||||
|
if !h.dbMgr.DBExists(webhookID) {
|
||||||
|
return 0, 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
failed, err := countEventsWithStatus(
|
||||||
|
webhookDB, webhookID, eventLogStatuses(showFailed),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pending, err := countEventsWithStatus(
|
||||||
|
webhookDB, webhookID, eventLogStatuses(showPending),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return failed, pending, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resubmitCounts reports, for each of the page's events, how many
|
||||||
|
// events have been resubmitted from it.
|
||||||
|
//
|
||||||
|
// One grouped query covers the page rather than one query per event.
|
||||||
|
// The page shows at most recentEventLimit events, far below SQLite's
|
||||||
|
// bound parameter ceiling, so it needs no chunking as the delivery
|
||||||
|
// result load does.
|
||||||
|
func resubmitCounts(
|
||||||
|
webhookDB *gorm.DB, eventIDs []string,
|
||||||
|
) (map[string]int, error) {
|
||||||
|
counts := make(map[string]int, len(eventIDs))
|
||||||
|
|
||||||
|
if len(eventIDs) == 0 {
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows []struct {
|
||||||
|
ResubmittedFromID string
|
||||||
|
Total int
|
||||||
|
}
|
||||||
|
|
||||||
|
err := webhookDB.Model(&database.Event{}).
|
||||||
|
Select("resubmitted_from_id, count(*) AS total").
|
||||||
|
Where("resubmitted_from_id IN ?", eventIDs).
|
||||||
|
Group("resubmitted_from_id").
|
||||||
|
Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
counts[row.ResubmittedFromID] = row.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// deliveryIDChunkSize bounds how many delivery IDs go into one
|
||||||
|
// IN clause. SQLite refuses a statement carrying more than
|
||||||
|
// SQLITE_MAX_VARIABLE_NUMBER (32766) bound parameters, and a
|
||||||
|
// page holds one delivery per target per event, so a webhook
|
||||||
|
// with enough targets would turn the whole query into an error
|
||||||
|
// and the page into zero attempts.
|
||||||
|
const deliveryIDChunkSize = 500
|
||||||
|
|
||||||
|
// loadDeliveryResults loads the recorded attempts for the
|
||||||
|
// page's deliveries, keyed by delivery ID.
|
||||||
|
//
|
||||||
|
// Each response body is cut by SQLite rather than in Go, for
|
||||||
|
// the reason deliveryResultColumns gives. How many attempts a
|
||||||
|
// delivery has is the target's MaxRetries, which the
|
||||||
|
// authenticated operator sets; how many of them reach the page
|
||||||
|
// is bounded again by maxRenderedAttempts.
|
||||||
|
func (h *Handlers) loadDeliveryResults(
|
||||||
|
webhookDB *gorm.DB,
|
||||||
|
deliveryIDs []string,
|
||||||
|
) (map[string][]deliveryResultRow, error) {
|
||||||
|
byDelivery := make(map[string][]deliveryResultRow)
|
||||||
|
|
||||||
|
for chunk := range slices.Chunk(
|
||||||
|
deliveryIDs, deliveryIDChunkSize,
|
||||||
|
) {
|
||||||
|
var rows []deliveryResultRow
|
||||||
|
|
||||||
|
err := webhookDB.Model(
|
||||||
|
&database.DeliveryResult{},
|
||||||
|
).Select(
|
||||||
|
deliveryResultColumns, maxRenderedResponseBytes,
|
||||||
|
).Where(
|
||||||
|
"delivery_id IN ?", chunk,
|
||||||
|
).Order("attempt_num ASC").Find(&rows).Error
|
||||||
|
if err != nil {
|
||||||
|
// Returning what was loaded so far renders the
|
||||||
|
// deliveries in the failed chunk as never having run,
|
||||||
|
// which is indistinguishable from ones that really
|
||||||
|
// never ran. The page fails instead.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range rows {
|
||||||
|
byDelivery[rows[i].DeliveryID] = append(
|
||||||
|
byDelivery[rows[i].DeliveryID], rows[i],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return byDelivery, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newDeliveryViews projects deliveries for rendering,
|
||||||
|
// resolving each one's target to its display-safe view and
|
||||||
|
// each one's attempts through that target's redactor. A
|
||||||
|
// retrying delivery also reads its target's circuit breaker.
|
||||||
|
func (h *Handlers) newDeliveryViews(
|
||||||
|
deliveries []database.Delivery,
|
||||||
|
targetMap map[string]eventLogTarget,
|
||||||
|
attempts map[string][]deliveryResultRow,
|
||||||
|
) []DeliveryView {
|
||||||
|
views := make([]DeliveryView, len(deliveries))
|
||||||
|
|
||||||
|
for i := range deliveries {
|
||||||
|
target := targetMap[deliveries[i].TargetID]
|
||||||
|
rows := attempts[deliveries[i].ID]
|
||||||
|
created := deliveries[i].CreatedAt
|
||||||
|
|
||||||
|
results, omitted := renderedAttempts(
|
||||||
|
rows, target.Redactor,
|
||||||
|
)
|
||||||
|
|
||||||
|
views[i] = DeliveryView{
|
||||||
|
ID: deliveries[i].ID,
|
||||||
|
Status: deliveries[i].Status,
|
||||||
|
Target: target.View,
|
||||||
|
Replay: deliveries[i].Replay,
|
||||||
|
Created: humanize.Time(created),
|
||||||
|
CreatedUTC: created.UTC().Format(time.DateTime) + " UTC",
|
||||||
|
Results: results,
|
||||||
|
AttemptCount: len(rows),
|
||||||
|
AttemptsOmitted: omitted,
|
||||||
|
}
|
||||||
|
|
||||||
|
if deliveries[i].Status == database.DeliveryStatusRetrying {
|
||||||
|
views[i].Paused = h.deliveryPausedView(
|
||||||
|
deliveries[i].TargetID, rows,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxRenderedAttempts bounds how many of one delivery's
|
||||||
|
// attempts the page renders. Past it the middle is dropped and
|
||||||
|
// counted, keeping the first attempts and the last ones: how
|
||||||
|
// the delivery started failing and how it ended are what a
|
||||||
|
// reader needs, and the count says plainly that the rest was
|
||||||
|
// dropped rather than never recorded.
|
||||||
|
const (
|
||||||
|
renderedAttemptsHead = 10
|
||||||
|
renderedAttemptsTail = 10
|
||||||
|
maxRenderedAttempts = renderedAttemptsHead +
|
||||||
|
renderedAttemptsTail
|
||||||
|
)
|
||||||
|
|
||||||
|
// renderedAttempts projects a delivery's attempts through the
|
||||||
|
// target's redactor, at most maxRenderedAttempts of them, and
|
||||||
|
// reports how many it dropped.
|
||||||
|
func renderedAttempts(
|
||||||
|
rows []deliveryResultRow,
|
||||||
|
redactor delivery.Redactor,
|
||||||
|
) ([]DeliveryResultView, int) {
|
||||||
|
omitted := 0
|
||||||
|
|
||||||
|
if len(rows) > maxRenderedAttempts {
|
||||||
|
omitted = len(rows) - maxRenderedAttempts
|
||||||
|
|
||||||
|
kept := make(
|
||||||
|
[]deliveryResultRow, 0, maxRenderedAttempts,
|
||||||
|
)
|
||||||
|
kept = append(kept, rows[:renderedAttemptsHead]...)
|
||||||
|
kept = append(
|
||||||
|
kept, rows[len(rows)-renderedAttemptsTail:]...,
|
||||||
|
)
|
||||||
|
rows = kept
|
||||||
|
}
|
||||||
|
|
||||||
|
views := make([]DeliveryResultView, len(rows))
|
||||||
|
for i := range rows {
|
||||||
|
views[i] = rows[i].view(redactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
return views, omitted
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestEventLog_TimesCarryTheirZone proves that the event log shows
|
||||||
|
// when an event arrived, and that it and the event's page show when
|
||||||
|
// each delivery was created and each attempt recorded: each as how
|
||||||
|
// long ago, with the full UTC time on hover.
|
||||||
|
func TestEventLog_TimesCarryTheirZone(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Truncate(time.Second)
|
||||||
|
receivedAt := now.Add(-3 * time.Hour)
|
||||||
|
createdAt := now.Add(-90 * time.Minute)
|
||||||
|
ranAt := now.Add(-30 * time.Minute)
|
||||||
|
|
||||||
|
event := f.event(t, contentTypeJSON, "{}", receivedAt)
|
||||||
|
dlv := f.deliveryQueuedAt(
|
||||||
|
t, event, target.ID, database.DeliveryStatusDelivered, createdAt,
|
||||||
|
)
|
||||||
|
f.attempt(t, dlv, http.StatusOK, ranAt.Sub(createdAt))
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
eventPage := w.Body.String()
|
||||||
|
eventLog := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
|
||||||
|
received := receivedAt.Format(time.DateTime)
|
||||||
|
assert.Contains(t, eventLog, `title="`+received+` UTC">3 hours ago</span>`)
|
||||||
|
assert.NotContains(t, eventLog, received+"</span>",
|
||||||
|
"an event's time must not be written without its zone")
|
||||||
|
assert.Contains(t, eventPage, received+" UTC")
|
||||||
|
|
||||||
|
for _, page := range []string{eventLog, eventPage} {
|
||||||
|
assert.Contains(t, page, `title="`+createdAt.Format(time.DateTime)+
|
||||||
|
` UTC">created 1 hour ago</span>`)
|
||||||
|
assert.Contains(t, page, `title="`+ranAt.Format(time.DateTime)+
|
||||||
|
` UTC">30 minutes ago</span>`)
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user