Seed from go-template-repo, renamed to simplexcalc
The template's files at a77fd30, without its history or LICENSE, after script/rename simplexcalc. Model: opus-5-5
This commit is contained in:
@@ -0,0 +1,26 @@
|
|||||||
|
# Never exclude Go sources, go.mod, go.sum or .golangci.yml: the lint
|
||||||
|
# and test stages only ever examine what reaches the build context, and
|
||||||
|
# an exclusion here makes them pass over a tree that is missing a
|
||||||
|
# package. script/assert-context-complete exists to catch exactly that,
|
||||||
|
# and will fail the build rather than let it happen silently.
|
||||||
|
.git/
|
||||||
|
.gitea/
|
||||||
|
bin/
|
||||||
|
data/
|
||||||
|
AGENTS.md
|
||||||
|
README.md
|
||||||
|
docs/
|
||||||
|
LICENSE
|
||||||
|
.editorconfig
|
||||||
|
.prettierrc
|
||||||
|
.prettierignore
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
end_of_line = lf
|
||||||
|
charset = utf-8
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.go]
|
||||||
|
indent_style = tab
|
||||||
|
|
||||||
|
[Makefile]
|
||||||
|
indent_style = tab
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
name: check
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- "**"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
|
||||||
|
# The image build is the gate: it runs gofmt, config verify,
|
||||||
|
# lint and test, and script/cibuild refuses a build in which
|
||||||
|
# any of them was cached or skipped.
|
||||||
|
- name: cibuild
|
||||||
|
run: script/cibuild
|
||||||
+23
@@ -0,0 +1,23 @@
|
|||||||
|
/bin/
|
||||||
|
/data/
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
*.key
|
||||||
|
*.pem
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
.DS_Store
|
||||||
|
*~
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Editor and assistant tooling directories. These are per-developer state and
|
||||||
|
# never belong in the repository; documentation for contributors, human or
|
||||||
|
# otherwise, lives in AGENTS.md.
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.claude/
|
||||||
|
.cursor/
|
||||||
|
.aider*
|
||||||
|
.continue/
|
||||||
|
.windsurf/
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
version: "2"
|
||||||
|
|
||||||
|
run:
|
||||||
|
timeout: 5m
|
||||||
|
modules-download-mode: readonly
|
||||||
|
|
||||||
|
linters:
|
||||||
|
default: all
|
||||||
|
disable:
|
||||||
|
# Genuinely incompatible with project patterns
|
||||||
|
- exhaustruct # Requires all struct fields
|
||||||
|
- depguard # Dependency allow/block lists
|
||||||
|
- godot # Requires comments to end with periods
|
||||||
|
- wsl # Deprecated, replaced by wsl_v5
|
||||||
|
- wrapcheck # Too verbose for internal packages
|
||||||
|
- varnamelen # Short names like db, id are idiomatic Go
|
||||||
|
settings:
|
||||||
|
lll:
|
||||||
|
line-length: 88
|
||||||
|
funlen:
|
||||||
|
lines: 80
|
||||||
|
statements: 50
|
||||||
|
cyclop:
|
||||||
|
max-complexity: 15
|
||||||
|
dupl:
|
||||||
|
threshold: 100
|
||||||
|
tagliatelle:
|
||||||
|
# snake_case JSON, deliberately. Every operational consumer
|
||||||
|
# of these payloads — jq in a shell, a Prometheus exporter
|
||||||
|
# config, a monitoring probe — is easier to write against
|
||||||
|
# snake_case, and the exposition format next door already
|
||||||
|
# uses it. This is a repo-wide decision, not a per-struct
|
||||||
|
# exception, so it lives here rather than in nolint
|
||||||
|
# directives spread across the handlers.
|
||||||
|
case:
|
||||||
|
rules:
|
||||||
|
json: snake
|
||||||
|
|
||||||
|
issues:
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
bin/
|
||||||
|
data/
|
||||||
|
go.sum
|
||||||
|
LICENSE
|
||||||
|
|
||||||
|
# Go templates are not HTML as far as prettier is concerned: the
|
||||||
|
# {{ ... }} actions sit in attribute and element position and prettier
|
||||||
|
# reformats them into invalid template source.
|
||||||
|
templates/
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"useTabs": false,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"proseWrap": "always",
|
||||||
|
"printWidth": 72,
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": false,
|
||||||
|
"trailingComma": "all"
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
---
|
||||||
|
title: Agent Guidance
|
||||||
|
last_modified: 2026-08-22
|
||||||
|
---
|
||||||
|
|
||||||
|
This file is the single source of guidance for any automated agent
|
||||||
|
working in this repository. There is no other agent-facing file, and
|
||||||
|
none is to be created: no vendor-named directory and no vendor-named
|
||||||
|
markdown file is ever committed.
|
||||||
|
|
||||||
|
Keeping such directories out of commits is what `.gitignore` is for, and
|
||||||
|
listing them there is correct — an ignore entry is plumbing, not
|
||||||
|
documentation. What must not appear is vendor-named _content_: committed
|
||||||
|
configuration, instructions or notes addressed to one particular tool.
|
||||||
|
Guidance goes here instead, written so that any agent can follow it.
|
||||||
|
|
||||||
|
Nothing in this repository names an assistant, model or vendor in its
|
||||||
|
prose. That rule propagates: every project seeded from this template
|
||||||
|
inherits it.
|
||||||
|
|
||||||
|
Read `docs/REPO_POLICIES.md` as well. Where this file and that one
|
||||||
|
overlap, they agree; where this file is silent, that one governs.
|
||||||
|
|
||||||
|
## What this repository is
|
||||||
|
|
||||||
|
A template. It is a working HTTP service — it builds, tests, lints and
|
||||||
|
runs — whose purpose is to be copied. Nothing in it is precious. The
|
||||||
|
`widgets` table, the index page and the debug panic route are there to
|
||||||
|
prove the machinery works end to end, and are meant to be deleted.
|
||||||
|
|
||||||
|
## Iron rules
|
||||||
|
|
||||||
|
These are not preferences. A change that breaks one of them is wrong
|
||||||
|
regardless of what else it achieves.
|
||||||
|
|
||||||
|
1. **Never mention any assistant, model, vendor, or the involvement of
|
||||||
|
automated assistance** — not in code, comments, documentation, commit
|
||||||
|
messages, PR bodies, or trailers. No `Co-Authored-By`, no session
|
||||||
|
links, no attribution of any kind.
|
||||||
|
|
||||||
|
2. **Linting runs in Docker, never on the host.** `script/lint` builds
|
||||||
|
`Dockerfile.lint` against a digest-pinned image. Do not install
|
||||||
|
`golangci-lint` locally and do not run it directly.
|
||||||
|
|
||||||
|
3. **Never run `go build`, `go test`, `go vet`, `gofmt` or
|
||||||
|
`golangci-lint` directly.** Use the `make` targets or the `script/`
|
||||||
|
entrypoints. They carry the flags and policies (`-count=1`, `-race`,
|
||||||
|
timeouts, docker-only linting) that a raw invocation silently
|
||||||
|
bypasses. This applies to mid-task checks, not just the final one.
|
||||||
|
|
||||||
|
4. **Configuration that is set but unparseable aborts startup.** A
|
||||||
|
default applies only to a value that is ABSENT. Silently substituting
|
||||||
|
a default for a value the operator got wrong turns their mistake into
|
||||||
|
a misconfiguration that surfaces much later, somewhere else. See
|
||||||
|
`internal/config`. Reject any change that weakens this.
|
||||||
|
|
||||||
|
5. **Never use scripted search-and-replace to edit files** — no
|
||||||
|
`sed -i`, `perl -pi`, `awk` rewrites, or scripted heredocs. Read the
|
||||||
|
file and edit it, even when there are many similar edits. The single
|
||||||
|
exception is `script/rename`, which is a tool an operator runs once,
|
||||||
|
against a known-shaped tree, at seed time — and which deletes itself
|
||||||
|
afterwards.
|
||||||
|
|
||||||
|
6. **Formatting only via `make fmt`.** Never hand-roll a reformat.
|
||||||
|
`make fmt-check` is the read-only form and must stay non-mutating.
|
||||||
|
|
||||||
|
7. **Pin every external reference by hash.** Docker base images by
|
||||||
|
`@sha256:`, Actions by commit SHA, Go modules by `go.sum`. A version
|
||||||
|
tag is server-mutable and therefore remote code execution.
|
||||||
|
|
||||||
|
8. **Clean up every container and image you create. Never run
|
||||||
|
`docker builder prune`, `docker image prune`, or any other prune.**
|
||||||
|
Shared infrastructure purges on its own schedule; a prune destroys
|
||||||
|
other people's work in progress.
|
||||||
|
|
||||||
|
9. **Write verbatim identifiers in backticks in markdown** — branch
|
||||||
|
names, filenames, commands, environment variables. This is what
|
||||||
|
distinguishes a branch literally named `next` from the word "none"
|
||||||
|
meaning no such branch exists.
|
||||||
|
|
||||||
|
## Entrypoints
|
||||||
|
|
||||||
|
Everything is a `script/` entrypoint with a thin `make` shim. The
|
||||||
|
scripts are the real interface; `make` exists because fingers know it.
|
||||||
|
See the Entrypoints section of `README.md` for what each one does.
|
||||||
|
|
||||||
|
The two that matter most:
|
||||||
|
|
||||||
|
- `make check` — `test` + `lint` + `fmt-check`. Never modifies files.
|
||||||
|
This is the gate for a commit.
|
||||||
|
- `script/cibuild` — the container build with the lint and test stages
|
||||||
|
cache-busted, plus assertions that they really ran and really saw the
|
||||||
|
whole repository. This is the gate for a push, and the Gitea workflow
|
||||||
|
runs it.
|
||||||
|
|
||||||
|
A green `docker build` on its own proves nothing: an unchanged tree
|
||||||
|
serves every layer from cache and exits 0 having executed nothing, and a
|
||||||
|
`.dockerignore` entry can hide a package from a linter that then
|
||||||
|
truthfully reports `0 issues.` over what is left.
|
||||||
|
`script/assert-step-ran` and `script/assert-context-complete` exist to
|
||||||
|
close both holes, and their comments explain what they still cannot see.
|
||||||
|
Do not weaken them.
|
||||||
|
|
||||||
|
## Branching model
|
||||||
|
|
||||||
|
- `main` is the default branch and is kept green.
|
||||||
|
- Work happens on a branch off `main`.
|
||||||
|
- Merge to `main` directly when the branch is not protected; otherwise
|
||||||
|
open a PR.
|
||||||
|
- `docs/TODO.md` changes go in the same commit as the work they
|
||||||
|
describe.
|
||||||
|
- Push finished work to the remote. Work that exists only locally is
|
||||||
|
treated as lost.
|
||||||
|
- Never force-push, and never rewrite published history.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/simplexcalc/ cobra command tree; main() and serve
|
||||||
|
internal/app/ the fx object graph — the only place that knows
|
||||||
|
which concrete type satisfies what
|
||||||
|
internal/config/ viper-backed configuration; the abort-on-garbage rule
|
||||||
|
internal/database/ sqlite handle, embedded schema, migration runner
|
||||||
|
internal/globals/ build-time metadata (ldflags)
|
||||||
|
internal/handlers/ HTTP handlers, one struct, fx-injected dependencies
|
||||||
|
internal/logger/ log/slog, JSON always
|
||||||
|
internal/middleware/ request id, logging, metrics, recovery, timeout,
|
||||||
|
body cap, security headers, CSRF
|
||||||
|
internal/render/ template compilation and execution
|
||||||
|
internal/server/ lifecycle, route table, static file serving
|
||||||
|
internal/telemetry/ Sentry and Prometheus
|
||||||
|
templates/ go:embed HTML: base document, pages, partials
|
||||||
|
static/ go:embed CSS and JS
|
||||||
|
script/ the entrypoints
|
||||||
|
```
|
||||||
|
|
||||||
|
## Conventions
|
||||||
|
|
||||||
|
- **Dependency injection through `fx`.** A constructor takes an
|
||||||
|
`fx.Lifecycle` and a `Params` struct; it never reaches for a
|
||||||
|
package-level singleton. Returning an error from a constructor aborts
|
||||||
|
startup, which is how configuration failures become refusals to start.
|
||||||
|
- **Logging is `log/slog` only.** Never `zerolog`, never `logrus`. JSON
|
||||||
|
output in every environment. `net/http`'s own error output is routed
|
||||||
|
through the same handler so that one process emits one format.
|
||||||
|
- **Errors wrap with `%w`** and are compared with `errors.Is`. Sentinel
|
||||||
|
errors are package-level `var`s named `errThing`.
|
||||||
|
- **A handler never shows an error's text to a client.** The client gets
|
||||||
|
a chosen message; the error goes to the log and to Sentry, joined by
|
||||||
|
the request id that is also in the response header.
|
||||||
|
- **Route labels in metrics are chi route PATTERNS, never paths.**
|
||||||
|
Labelling by path makes every distinct URL a new time series, and a
|
||||||
|
crawler then owns the process's memory.
|
||||||
|
- **Tests exercise behaviour, not implementation.** The server tests
|
||||||
|
start a real listener against a real database and speak HTTP to it.
|
||||||
|
Prefer that over asserting on internals.
|
||||||
|
- **Comments explain why, and traps.** Delete the history, the
|
||||||
|
reasoning-out-loud and the self-justification; keep what a reader
|
||||||
|
needs in order not to fall in.
|
||||||
|
|
||||||
|
## What to change when seeding a new project
|
||||||
|
|
||||||
|
1. Clone, then run `script/rename <newname> [module-path]` on a clean
|
||||||
|
tree. It rewrites the placeholder name and module path everywhere,
|
||||||
|
renames `cmd/simplexcalc`, and deletes itself. Review the diff.
|
||||||
|
2. Delete the example domain:
|
||||||
|
`internal/database/schema/001_widgets.sql`,
|
||||||
|
`internal/database/model_widget.go`, the widget parts of
|
||||||
|
`internal/handlers/index.go` and `templates/index.html`, and their
|
||||||
|
tests. Start your own schema at `001`.
|
||||||
|
3. Rewrite `README.md` for the new project. It currently describes the
|
||||||
|
template, which the new project is not.
|
||||||
|
4. Rewrite this file's "What this repository is" section, and delete
|
||||||
|
this list. Keep the iron rules, the entrypoints, the branching model
|
||||||
|
and the conventions — they are why the template exists.
|
||||||
|
5. Reset `docs/TODO.md`: keep the Workflow section, replace the rest.
|
||||||
|
6. Decide on `CSRF_KEY` and the metrics credentials before the first
|
||||||
|
deployment. See `README.md` for what each one does when unset.
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
# Lint stage — fast feedback on formatting and lint issues. Tools are
|
||||||
|
# invoked directly (not via make/script): the docker build is its own
|
||||||
|
# single path.
|
||||||
|
# This stage must stay the one that runs golangci-lint, and its name must
|
||||||
|
# match $lint_stage in script/cibuild, which cache-busts it by name.
|
||||||
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Inventory of the sources that actually arrived here. script/cibuild
|
||||||
|
# reads these lines out of the build log and compares them against the
|
||||||
|
# git index, so a .dockerignore entry or a narrowed COPY that hides a
|
||||||
|
# package fails the run instead of yielding a clean report over a tree
|
||||||
|
# the linter never saw. Keep it immediately after `COPY . .`, and keep
|
||||||
|
# `echo context-manifest-begin` as its first command:
|
||||||
|
# script/assert-context-complete matches the step by that prefix.
|
||||||
|
RUN echo context-manifest-begin; \
|
||||||
|
{ find . -type f -name '*.go'; \
|
||||||
|
for f in go.mod go.sum .golangci.yml .golangci.yaml; do \
|
||||||
|
if [ -f "$f" ]; then echo "./$f"; fi; \
|
||||||
|
done; } \
|
||||||
|
| sed 's|^\./||' | LC_ALL=C sort | sed 's|^|context-file: |'; \
|
||||||
|
echo context-manifest-end
|
||||||
|
|
||||||
|
# Formatting check, config check, linter
|
||||||
|
RUN test -z "$(gofmt -s -l .)" || { echo "gofmt needed on:"; gofmt -s -l .; exit 1; }
|
||||||
|
RUN golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
|
# Build stage. Must stay the one that runs go test, and its name must
|
||||||
|
# match $test_stage in script/cibuild, which cache-busts it by name.
|
||||||
|
# golang:1.25.7-bookworm (Debian-based: the race detector used by the
|
||||||
|
# test run requires glibc), 2026-08-07
|
||||||
|
FROM golang:1.25.7-bookworm@sha256:564e366a28ad1d70f460a2b97d1d299a562f08707eb0ecb24b659e5bd6c108e1 AS builder
|
||||||
|
|
||||||
|
# Depend on lint stage passing (forces BuildKit ordering)
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy go mod files first for better layer caching
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Same inventory as the lint stage, and separately checked: this stage
|
||||||
|
# has its own COPY, so an intact context over there is no evidence about
|
||||||
|
# the tree `go test ./...` is about to walk here. A package that did not
|
||||||
|
# arrive is a package the tests never run, and the run still ends `ok`.
|
||||||
|
RUN echo context-manifest-begin; \
|
||||||
|
{ find . -type f -name '*.go'; \
|
||||||
|
for f in go.mod go.sum .golangci.yml .golangci.yaml; do \
|
||||||
|
if [ -f "$f" ]; then echo "./$f"; fi; \
|
||||||
|
done; } \
|
||||||
|
| sed 's|^\./||' | LC_ALL=C sort | sed 's|^|context-file: |'; \
|
||||||
|
echo context-manifest-end
|
||||||
|
|
||||||
|
# Run tests: quiet first, verbose rerun on failure (and still fail).
|
||||||
|
# -count=1 disables the test result cache, matching script/test.
|
||||||
|
RUN go test -count=1 -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
|
||||||
|
# Static build; modernc.org/sqlite is pure Go, so CGO_ENABLED=0 yields
|
||||||
|
# a fully static binary that runs on the Alpine runtime.
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||||
|
-o bin/simplexcalc ./cmd/simplexcalc
|
||||||
|
|
||||||
|
# Runtime stage, and the last one: script/cibuild passes no --target, so
|
||||||
|
# BuildKit builds whichever stage is last and appending one drops lint,
|
||||||
|
# builder and their checks out of the run. Nothing asserts the name; it
|
||||||
|
# is here so that failure reads as `[runtime n/m]` in the build log.
|
||||||
|
# alpine:3.22, 2026-08-07
|
||||||
|
FROM alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce AS runtime
|
||||||
|
|
||||||
|
RUN apk --no-cache add ca-certificates
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1000 -S simplexcalc && \
|
||||||
|
adduser -u 1000 -S simplexcalc -G simplexcalc
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy binary from builder
|
||||||
|
COPY --from=builder /build/bin/simplexcalc /app/simplexcalc
|
||||||
|
|
||||||
|
# Data directory: the sqlite database lives here. Mount a volume over
|
||||||
|
# it in production; everything else in the image is read-only.
|
||||||
|
RUN mkdir -p /var/lib/simplexcalc
|
||||||
|
|
||||||
|
RUN chown -R simplexcalc:simplexcalc /app /var/lib/simplexcalc
|
||||||
|
|
||||||
|
USER simplexcalc
|
||||||
|
|
||||||
|
ENV DATA_DIR=/var/lib/simplexcalc
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck.json || exit 1
|
||||||
|
|
||||||
|
CMD ["/app/simplexcalc", "serve"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Lint image, built by script/lint: golangci-lint runs as a build step, so
|
||||||
|
# a successful build is a clean lint. Works with a remote docker daemon,
|
||||||
|
# where bind mounts are impossible.
|
||||||
|
|
||||||
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
|
||||||
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# This stage must stay the last one and the one that runs golangci-lint,
|
||||||
|
# and its name must match $stage in script/lint.
|
||||||
|
FROM deps AS lint
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Inventory of the sources that actually arrived here. script/lint reads
|
||||||
|
# these lines out of the build log and compares them against the git
|
||||||
|
# index, so a .dockerignore entry or a narrowed COPY that hides a
|
||||||
|
# package fails the run instead of yielding a clean report over a tree
|
||||||
|
# the linter never saw. Keep it immediately after `COPY . .`, and keep
|
||||||
|
# `echo context-manifest-begin` as its first command:
|
||||||
|
# script/assert-context-complete matches the step by that prefix.
|
||||||
|
RUN echo context-manifest-begin; \
|
||||||
|
{ find . -type f -name '*.go'; \
|
||||||
|
for f in go.mod go.sum .golangci.yml .golangci.yaml; do \
|
||||||
|
if [ -f "$f" ]; then echo "./$f"; fi; \
|
||||||
|
done; } \
|
||||||
|
| sed 's|^\./||' | LC_ALL=C sort | sed 's|^|context-file: |'; \
|
||||||
|
echo context-manifest-end
|
||||||
|
|
||||||
|
RUN golangci-lint config verify --config .golangci.yml
|
||||||
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker cibuild clean hooks
|
||||||
|
|
||||||
|
# Every target is a thin shim over script/. The scripts are the real
|
||||||
|
# entrypoints: they carry the project-specific knowledge (flags,
|
||||||
|
# timeouts, docker-only linting) that a raw `go test` or `golangci-lint`
|
||||||
|
# invocation silently bypasses. Never call the underlying tools
|
||||||
|
# directly.
|
||||||
|
.DEFAULT_GOAL := check
|
||||||
|
|
||||||
|
bootstrap:
|
||||||
|
@script/bootstrap
|
||||||
|
|
||||||
|
setup:
|
||||||
|
@script/setup
|
||||||
|
|
||||||
|
test:
|
||||||
|
@script/test
|
||||||
|
|
||||||
|
lint:
|
||||||
|
@script/lint
|
||||||
|
|
||||||
|
fmt:
|
||||||
|
@script/fmt
|
||||||
|
|
||||||
|
fmt-check:
|
||||||
|
@script/fmt-check
|
||||||
|
|
||||||
|
check:
|
||||||
|
@script/check
|
||||||
|
|
||||||
|
build:
|
||||||
|
go build -o bin/simplexcalc ./cmd/simplexcalc
|
||||||
|
|
||||||
|
run: build
|
||||||
|
./bin/simplexcalc serve
|
||||||
|
|
||||||
|
dev:
|
||||||
|
go run ./cmd/simplexcalc serve
|
||||||
|
|
||||||
|
deps:
|
||||||
|
go mod download
|
||||||
|
go mod tidy
|
||||||
|
|
||||||
|
docker:
|
||||||
|
@script/docker
|
||||||
|
|
||||||
|
cibuild:
|
||||||
|
@script/cibuild
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf bin/
|
||||||
|
|
||||||
|
hooks:
|
||||||
|
@script/install-precommit
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Command simplexcalc is the service entrypoint.
|
||||||
|
//
|
||||||
|
// Seeding a new project: script/rename renames this directory, the
|
||||||
|
// module path and the binary. Nothing here needs editing by hand.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
)
|
||||||
|
|
||||||
|
// appname is the name the service reports in logs, metrics and the
|
||||||
|
// healthcheck.
|
||||||
|
const appname = "simplexcalc"
|
||||||
|
|
||||||
|
// version is injected at build time with -ldflags "-X main.version=...".
|
||||||
|
// The Dockerfile passes VERSION; a `go build` without it says "dev",
|
||||||
|
// which is exactly what a locally built binary is. It is a package
|
||||||
|
// variable because the linker has no other way to reach it.
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
globals.Appname = appname
|
||||||
|
globals.Version = version
|
||||||
|
globals.Buildarch = runtime.GOARCH
|
||||||
|
|
||||||
|
err := rootCmd().Execute()
|
||||||
|
if err != nil {
|
||||||
|
// cobra has already printed the error; this only sets the exit
|
||||||
|
// status, which is what a supervisor and a shell script read.
|
||||||
|
fmt.Fprintln(os.Stderr, "exiting: "+err.Error())
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rootCmd builds the command tree. Configuration comes from the
|
||||||
|
// environment (see internal/config), not from flags: the service runs
|
||||||
|
// in containers, where the environment is the interface, and one source
|
||||||
|
// of truth means there is no precedence rule to get wrong.
|
||||||
|
func rootCmd() *cobra.Command {
|
||||||
|
root := &cobra.Command{
|
||||||
|
Use: appname,
|
||||||
|
Short: appname + " — an HTTP service",
|
||||||
|
Long: appname + " is an HTTP service.\n\n" +
|
||||||
|
"Configuration is read from the environment and from an\n" +
|
||||||
|
"optional .env file in the working directory. See README.md\n" +
|
||||||
|
"for the full list of variables.",
|
||||||
|
SilenceUsage: true,
|
||||||
|
// Without this, cobra prints the error itself and main prints
|
||||||
|
// it again.
|
||||||
|
SilenceErrors: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
root.AddCommand(serveCmd(), versionCmd())
|
||||||
|
|
||||||
|
return root
|
||||||
|
}
|
||||||
|
|
||||||
|
func versionCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "print the version and exit",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s %s %s/%s\n",
|
||||||
|
appname, version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/app"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startTimeout bounds startup: opening the database, applying
|
||||||
|
// migrations and binding the port. A start that hangs past this is a
|
||||||
|
// start that failed, and saying so beats waiting forever.
|
||||||
|
const startTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// stopTimeout bounds the whole stop sequence. The server's own drain is
|
||||||
|
// bounded by SHUTDOWN_GRACE within this; the margin covers closing the
|
||||||
|
// database and flushing Sentry after the drain finishes.
|
||||||
|
const stopTimeout = 45 * time.Second
|
||||||
|
|
||||||
|
func serveCmd() *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "serve",
|
||||||
|
Short: "run the HTTP server",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
return serve(cmd.Context())
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serve builds the graph and runs until a signal arrives.
|
||||||
|
//
|
||||||
|
// fx.App.Run handles SIGINT and SIGTERM and then runs the stop
|
||||||
|
// sequence, which is why nothing here installs a signal handler: two
|
||||||
|
// handlers for the same signal is how a shutdown ends up half done.
|
||||||
|
func serve(ctx context.Context) error {
|
||||||
|
fxApp := app.New(
|
||||||
|
fx.StartTimeout(startTimeout),
|
||||||
|
fx.StopTimeout(stopTimeout),
|
||||||
|
)
|
||||||
|
|
||||||
|
// A construction error is reported here rather than by Run, which
|
||||||
|
// would exit the process itself and give the caller nothing to
|
||||||
|
// report. Configuration failures arrive through this path.
|
||||||
|
err := fxApp.Err()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("building application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
startCtx, cancel := context.WithTimeout(ctx, startTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err = fxApp.Start(startCtx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("starting application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until a signal. fx closes this channel after it receives
|
||||||
|
// one; the stop sequence is ours to run.
|
||||||
|
<-fxApp.Wait()
|
||||||
|
|
||||||
|
// Deliberately NOT derived from ctx: by the time this runs, the
|
||||||
|
// signal that ends the process has already cancelled it, and a
|
||||||
|
// stop context that is cancelled from the outset gives the drain
|
||||||
|
// no time at all — which is the abrupt termination the graceful
|
||||||
|
// shutdown exists to avoid.
|
||||||
|
stopCtx, stopCancel := context.WithTimeout(context.Background(), stopTimeout)
|
||||||
|
defer stopCancel()
|
||||||
|
|
||||||
|
//nolint:contextcheck // see above: the fresh context is the point.
|
||||||
|
err = fxApp.Stop(stopCtx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("stopping application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,446 @@
|
|||||||
|
---
|
||||||
|
title: Repository Policies
|
||||||
|
last_modified: 2026-08-07
|
||||||
|
---
|
||||||
|
|
||||||
|
This document covers repository structure, tooling, and workflow
|
||||||
|
standards. Code style conventions are in separate documents:
|
||||||
|
|
||||||
|
- [Code Styleguide](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE.md)
|
||||||
|
(general, bash, Docker)
|
||||||
|
- [Go](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_GO.md)
|
||||||
|
- [JavaScript](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_JS.md)
|
||||||
|
- [Python](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/CODE_STYLEGUIDE_PYTHON.md)
|
||||||
|
- [Go HTTP Server Conventions](https://git.eeqj.de/sneak/prompts/raw/branch/main/prompts/GO_HTTP_SERVER_CONVENTIONS.md)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
- Cross-project documentation (such as this file) must include
|
||||||
|
`last_modified: YYYY-MM-DD` in the YAML front matter so it can be kept
|
||||||
|
in sync with the authoritative source as policies evolve.
|
||||||
|
|
||||||
|
- **ALL external references must be pinned by cryptographic hash.** This
|
||||||
|
includes Docker base images, Go modules, npm packages, GitHub Actions,
|
||||||
|
and anything else fetched from a remote source. Version tags (`@v4`,
|
||||||
|
`@latest`, `:3.21`, etc.) are server-mutable and therefore remote code
|
||||||
|
execution vulnerabilities. The ONLY acceptable way to reference an
|
||||||
|
external dependency is by its content hash (Docker `@sha256:...`, Go
|
||||||
|
module hash in `go.sum`, npm integrity hash in lockfile, GitHub
|
||||||
|
Actions `@<commit-sha>`). No exceptions. This also means never
|
||||||
|
`curl | bash` to install tools like pyenv, nvm, rustup, etc. Instead,
|
||||||
|
download a specific release archive from GitHub, verify its hash
|
||||||
|
(hardcoded in the Dockerfile or script), and only then install.
|
||||||
|
Unverified install scripts are arbitrary remote code execution. This
|
||||||
|
is the single most important rule in this document. Double-check every
|
||||||
|
external reference in every file before committing. There are zero
|
||||||
|
exceptions to this rule.
|
||||||
|
|
||||||
|
- Every repo with software must have a root `Makefile` with these
|
||||||
|
targets: `make bootstrap`, `make setup`, `make test`, `make lint`,
|
||||||
|
`make fmt` (writes), `make fmt-check` (read-only), `make check` (runs
|
||||||
|
`test`, `lint`, `fmt-check`), `make docker`, and `make hooks`
|
||||||
|
(installs pre-commit hook). A model Makefile is at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/Makefile`.
|
||||||
|
|
||||||
|
- Repos follow the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
pattern: the implementation of each Makefile target lives in an
|
||||||
|
executable script in `script/` (`script/bootstrap`, `script/setup`,
|
||||||
|
`script/test`, `script/lint`, `script/fmt`, `script/fmt-check`,
|
||||||
|
`script/check`, `script/docker`), and the Makefile targets are thin
|
||||||
|
shims that call them. The scripts must be POSIX sh (`#!/bin/sh`,
|
||||||
|
`set -eu`, no bashisms) so they run in minimal containers (e.g. alpine
|
||||||
|
images have no bash); locate the repo root with
|
||||||
|
`$(cd "$(dirname "$0")/.." && pwd -P)` and `cd` there before acting.
|
||||||
|
From the standard's canonical set we use `bootstrap`, `setup` (make
|
||||||
|
the repo ready for development after a fresh clone: runs `bootstrap`,
|
||||||
|
then `install-precommit`, plus any repo-specific initialization),
|
||||||
|
`test`, and `cibuild`. `script/bootstrap` installs all dependencies
|
||||||
|
idempotently and assumes nothing is present: base tools come from nix,
|
||||||
|
apt, brew, or apk (detected in that order; apt runs noninteractive).
|
||||||
|
For node it uses the installed node if present; otherwise it installs
|
||||||
|
a PINNED node version via nvm, first installing nvm itself if missing
|
||||||
|
— from a hash-verified GitHub release archive (never `curl | sh`),
|
||||||
|
with bash installed as an explicit prerequisite since nvm requires
|
||||||
|
bash. yarn is then pinned via
|
||||||
|
`corepack prepare yarn@<version> --activate`. Never install "latest"
|
||||||
|
or "lts"; always exact versions. `script/cibuild` runs the CI build:
|
||||||
|
it changes to the repo root and runs `docker build .`; the Gitea
|
||||||
|
workflow calls it. Four further scripts are our own extensions to the
|
||||||
|
standard: `script/check` runs `script/test`, `script/lint`, and
|
||||||
|
`script/fmt-check`; `script/precommit` is what the git pre-commit hook
|
||||||
|
runs, and it calls `script/check`; `script/install-precommit` installs
|
||||||
|
the git pre-commit hook (the `make hooks` target shims to it); and
|
||||||
|
`script/projectname` (literally that filename) simply outputs the
|
||||||
|
project's name. Scripts that need the name call `script/projectname` —
|
||||||
|
e.g. `script/docker` assembles its image tag from it — so those
|
||||||
|
scripts stay byte-identical across all repos. Repo-type-specific
|
||||||
|
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong
|
||||||
|
in `script/precommit`, not in the hook itself. Model scripts are at
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The
|
||||||
|
README must document the provided scripts in an **Entrypoints**
|
||||||
|
section (see the README requirements below).
|
||||||
|
|
||||||
|
- Always use Makefile targets (`make fmt`, `make test`, `make lint`,
|
||||||
|
etc.) instead of invoking the underlying tools directly. The Makefile
|
||||||
|
is the single source of truth for how these operations are run.
|
||||||
|
|
||||||
|
- The Makefile is authoritative documentation for how the repo is used.
|
||||||
|
Beyond the required targets above, it should have targets for every
|
||||||
|
common operation: running a local development server (`make run`,
|
||||||
|
`make dev`), re-initializing or migrating the database
|
||||||
|
(`make db-reset`, `make migrate`), building artifacts (`make build`),
|
||||||
|
generating code, seeding data, or anything else a developer would do
|
||||||
|
regularly. If someone checks out the repo and types `make<tab>`, they
|
||||||
|
should see every meaningful operation available. A new contributor
|
||||||
|
should be able to understand the entire development workflow by
|
||||||
|
reading the Makefile.
|
||||||
|
|
||||||
|
- Every repo should have a `Dockerfile`. All Dockerfiles must run
|
||||||
|
`make check` as a build step so the build fails if the branch is not
|
||||||
|
green. For non-server repos, the Dockerfile should bring up a
|
||||||
|
development environment and run `make check`. For server repos,
|
||||||
|
`make check` should run as an early build stage before the final image
|
||||||
|
is assembled. Dockerfiles install development prerequisites by running
|
||||||
|
`script/bootstrap` rather than duplicating installs inline; COPY
|
||||||
|
`script/` and the dependency manifests (`package.json` + `yarn.lock`,
|
||||||
|
`go.mod` + `go.sum`, etc.) before running it so the bootstrap layer
|
||||||
|
stays cached until dependencies change.
|
||||||
|
|
||||||
|
- **Dockerfiles must use a separate lint stage for fail-fast feedback.**
|
||||||
|
Go repos use a multistage build where linting runs in an independent
|
||||||
|
stage based on the `golangci/golangci-lint` image (pinned by hash).
|
||||||
|
This stage runs `make fmt-check` and `make lint` before the full build
|
||||||
|
begins. The build stage then declares an explicit dependency on the
|
||||||
|
lint stage via `COPY --from=lint /src/go.sum /dev/null`, which forces
|
||||||
|
BuildKit to complete linting before proceeding to compilation and
|
||||||
|
tests. This ensures lint failures surface in seconds rather than
|
||||||
|
minutes, without blocking on dependency download or compilation in the
|
||||||
|
build stage.
|
||||||
|
|
||||||
|
The standard pattern for a Go repo Dockerfile is:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Lint stage — fast feedback on formatting and lint issues
|
||||||
|
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
|
||||||
|
FROM golangci/golangci-lint@sha256:... AS lint
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make fmt-check
|
||||||
|
RUN make lint
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
# golang:1.x-alpine, YYYY-MM-DD
|
||||||
|
FROM golang@sha256:... AS builder
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Force BuildKit to run the lint stage before proceeding
|
||||||
|
COPY --from=lint /src/go.sum /dev/null
|
||||||
|
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN make test
|
||||||
|
|
||||||
|
ARG VERSION=dev
|
||||||
|
RUN CGO_ENABLED=0 go build -trimpath \
|
||||||
|
-ldflags="-s -w -X main.Version=${VERSION}" \
|
||||||
|
-o /app ./cmd/app/
|
||||||
|
|
||||||
|
# Runtime stage
|
||||||
|
FROM alpine@sha256:...
|
||||||
|
COPY --from=builder /app /usr/local/bin/app
|
||||||
|
ENTRYPOINT ["app"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key points:
|
||||||
|
- The lint stage uses the `golangci/golangci-lint` image directly
|
||||||
|
(it includes both Go and the linter), so there is no need to
|
||||||
|
install the linter separately.
|
||||||
|
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that
|
||||||
|
creates a stage dependency. BuildKit runs stages in parallel by
|
||||||
|
default; without this line, the build stage would not wait for
|
||||||
|
lint to finish and a lint failure might not fail the overall
|
||||||
|
build.
|
||||||
|
- 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 create placeholder files so the embed directives
|
||||||
|
resolve. Example:
|
||||||
|
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
|
||||||
|
The lint stage should not depend on the actual build output — it
|
||||||
|
exists to fail fast.
|
||||||
|
- If the project requires CGO or system libraries for linting (e.g.
|
||||||
|
`vips-dev`), install them in the lint stage with `apk add`.
|
||||||
|
- The build stage runs `make test` after compilation setup. Tests
|
||||||
|
run in the build stage, not the lint stage, because they may
|
||||||
|
require compiled artifacts or heavier dependencies.
|
||||||
|
|
||||||
|
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`)
|
||||||
|
that runs `script/cibuild` (which runs `docker build .`) on push.
|
||||||
|
Since the Dockerfile already runs `make check`, a successful build
|
||||||
|
implies all checks pass.
|
||||||
|
|
||||||
|
- Use platform-standard formatters: `black` for Python, `prettier` for
|
||||||
|
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default
|
||||||
|
configuration with two exceptions: four-space indents (except Go), and
|
||||||
|
`proseWrap: always` for Markdown (hard-wrap at 80 columns).
|
||||||
|
Documentation and writing repos (Markdown, HTML, CSS) should also have
|
||||||
|
`.prettierrc` and `.prettierignore`.
|
||||||
|
|
||||||
|
- Pre-commit hook: runs `script/precommit`, which calls `script/check`.
|
||||||
|
If local testing is not possible in the repo, `script/precommit` may
|
||||||
|
skip `script/test` and run only `script/lint` and `script/fmt-check`.
|
||||||
|
The hook is installed by `script/install-precommit`; the Makefile must
|
||||||
|
provide a `make hooks` target that shims to it.
|
||||||
|
|
||||||
|
- All repos with software must have tests that run via the
|
||||||
|
platform-standard test framework (`go test`, `pytest`,
|
||||||
|
`jest`/`vitest`, etc.). If no meaningful tests exist yet, add the most
|
||||||
|
minimal test possible — e.g. importing the module under test to verify
|
||||||
|
it compiles/parses. There is no excuse for `make test` to be a no-op.
|
||||||
|
|
||||||
|
- `make test` must complete in under 60 seconds. That is the hard cap,
|
||||||
|
and a suite that exceeds it fails. Under 20 seconds is the target. A
|
||||||
|
suite between 20 and 60 seconds is still green, but the overage must
|
||||||
|
be filed as an improvement bug against that repo. Add a 90-second
|
||||||
|
timeout to the test invocation in the Makefile
|
||||||
|
(`go test -timeout 90s`). The backstop deliberately sits above the
|
||||||
|
hard cap so that it catches a genuinely hung test rather than a merely
|
||||||
|
slow one.
|
||||||
|
|
||||||
|
- **`make test` should use the conditional verbose rerun pattern.** Run
|
||||||
|
tests without `-v` (verbose) first. If tests fail, automatically rerun
|
||||||
|
with `-v` to show full output. This keeps CI logs and `docker build`
|
||||||
|
output clean on success (just package/suite summaries) while providing
|
||||||
|
full diagnostic detail on failure (every test case, every assertion).
|
||||||
|
The general shell pattern:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@<test-command> || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
<test-command-with-v>; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Go example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@go test -timeout 90s -race -cover ./... || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
go test -timeout 90s -race -v ./...; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Python example:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
@python -m pytest || \
|
||||||
|
{ echo "--- Rerunning with -v for details ---"; \
|
||||||
|
python -m pytest -v; exit 1; }
|
||||||
|
```
|
||||||
|
|
||||||
|
The `exit 1` ensures the target always fails after a rerun — the
|
||||||
|
first run already proved the tests are broken, so the build must not
|
||||||
|
pass even if a flaky test happens to succeed on the second attempt.
|
||||||
|
The rerun exists solely for diagnostic output.
|
||||||
|
|
||||||
|
- Docker builds must complete in under 5 minutes.
|
||||||
|
|
||||||
|
- `make check` must not modify any files in the repo. Tests may use
|
||||||
|
temporary directories.
|
||||||
|
|
||||||
|
- `main` must always pass `make check`, no exceptions.
|
||||||
|
|
||||||
|
- Never commit secrets. `.env` files, credentials, API keys, and private
|
||||||
|
keys must be in `.gitignore`. No exceptions.
|
||||||
|
|
||||||
|
- `.gitignore` should be comprehensive from the start: OS files
|
||||||
|
(`.DS_Store`), editor files (`.swp`, `*~`), language build artifacts,
|
||||||
|
and `node_modules/`. Fetch the standard `.gitignore` from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
|
||||||
|
setting up a new repo.
|
||||||
|
|
||||||
|
- **No build artifacts in version control.** Code-derived data (compiled
|
||||||
|
bundles, minified output, generated assets) must never be committed to
|
||||||
|
the repository if it can be avoided. The build process (e.g.
|
||||||
|
Dockerfile, Makefile) should generate these at build time. Notable
|
||||||
|
exception: Go protobuf generated files (`.pb.go`) ARE committed
|
||||||
|
because repos need to work with `go get`, which downloads code but
|
||||||
|
does not execute code generation.
|
||||||
|
|
||||||
|
- Never use `git add -A` or `git add .`. Always stage files explicitly
|
||||||
|
by name.
|
||||||
|
|
||||||
|
- Never force-push to `main`.
|
||||||
|
|
||||||
|
- Make all changes on a feature branch. You can do whatever you want on
|
||||||
|
a feature branch.
|
||||||
|
|
||||||
|
- `.golangci.yml` is standardized and must _NEVER_ be modified by an
|
||||||
|
agent, only manually by the user. Fetch from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The
|
||||||
|
canonical golangci-lint version is v2.12.2 (released 2026-05-06),
|
||||||
|
installed commit-pinned via
|
||||||
|
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
|
||||||
|
|
||||||
|
- When pinning images or packages by hash, add a comment above the
|
||||||
|
reference with the version and date (YYYY-MM-DD).
|
||||||
|
|
||||||
|
- Use `yarn`, not `npm`.
|
||||||
|
|
||||||
|
- Write all dates as YYYY-MM-DD (ISO 8601).
|
||||||
|
|
||||||
|
- Simple projects should be configured with environment variables.
|
||||||
|
|
||||||
|
- Dockerized web services listen on port 8080 by default, overridable
|
||||||
|
with `PORT`.
|
||||||
|
|
||||||
|
- **HTTP/web services must be hardened for production internet exposure
|
||||||
|
before tagging 1.0.** This means full compliance with security best
|
||||||
|
practices including, without limitation, all of the following:
|
||||||
|
- **Security headers** on every response:
|
||||||
|
- `Strict-Transport-Security` (HSTS) with `max-age` of at least
|
||||||
|
one year and `includeSubDomains`.
|
||||||
|
- `Content-Security-Policy` (CSP) with a restrictive default
|
||||||
|
policy (`default-src 'self'` as a baseline, tightened
|
||||||
|
per-resource as needed). Never use `unsafe-inline` or
|
||||||
|
`unsafe-eval` unless unavoidable, and document the reason.
|
||||||
|
- `X-Frame-Options: DENY` (or `SAMEORIGIN` if framing is
|
||||||
|
required). Prefer the `frame-ancestors` CSP directive as the
|
||||||
|
primary control.
|
||||||
|
- `X-Content-Type-Options: nosniff`.
|
||||||
|
- `Referrer-Policy: strict-origin-when-cross-origin` (or
|
||||||
|
stricter).
|
||||||
|
- `Permissions-Policy` restricting access to browser features
|
||||||
|
the application does not use (camera, microphone, geolocation,
|
||||||
|
etc.).
|
||||||
|
- **Request and response limits:**
|
||||||
|
- Maximum request body size enforced on all endpoints (e.g. Go
|
||||||
|
`http.MaxBytesReader`). Choose a sane default per-route; never
|
||||||
|
accept unbounded input.
|
||||||
|
- Maximum response body size where applicable (e.g. paginated
|
||||||
|
APIs).
|
||||||
|
- `ReadTimeout` and `ReadHeaderTimeout` on the `http.Server` to
|
||||||
|
defend against slowloris attacks.
|
||||||
|
- `WriteTimeout` on the `http.Server`.
|
||||||
|
- `IdleTimeout` on the `http.Server`.
|
||||||
|
- Per-handler execution time limits via `context.WithTimeout` or
|
||||||
|
chi/stdlib `middleware.Timeout`.
|
||||||
|
- **Authentication and session security:**
|
||||||
|
- Rate limiting on password-based authentication endpoints. API
|
||||||
|
keys are high-entropy and not susceptible to brute force, so
|
||||||
|
they are exempt.
|
||||||
|
- CSRF tokens on all state-mutating HTML forms. API endpoints
|
||||||
|
authenticated via `Authorization` header (Bearer token, API
|
||||||
|
key) are exempt because the browser does not attach these
|
||||||
|
automatically.
|
||||||
|
- Passwords stored using bcrypt, scrypt, or argon2 — never
|
||||||
|
plain-text, MD5, or SHA.
|
||||||
|
- Session cookies set with `HttpOnly`, `Secure`, and
|
||||||
|
`SameSite=Lax` (or `Strict`) attributes.
|
||||||
|
- **Reverse proxy awareness:**
|
||||||
|
- True client IP detection when behind a reverse proxy
|
||||||
|
(`X-Forwarded-For`, `X-Real-IP`). The application must accept
|
||||||
|
forwarded headers only from a configured set of trusted proxy
|
||||||
|
addresses — never trust `X-Forwarded-For` unconditionally.
|
||||||
|
- **CORS:**
|
||||||
|
- Authenticated endpoints must restrict
|
||||||
|
`Access-Control-Allow-Origin` to an explicit allowlist of
|
||||||
|
known origins. Wildcard (`*`) is acceptable only for public,
|
||||||
|
unauthenticated read-only APIs.
|
||||||
|
- **Error handling:**
|
||||||
|
- Internal errors must never leak stack traces, SQL queries,
|
||||||
|
file paths, or other implementation details to the client.
|
||||||
|
Return generic error messages in production; detailed errors
|
||||||
|
only when `DEBUG` is enabled.
|
||||||
|
- **TLS:**
|
||||||
|
- Services never terminate TLS directly. They are always
|
||||||
|
deployed behind a TLS-terminating reverse proxy. The service
|
||||||
|
itself listens on plain HTTP. However, HSTS headers and
|
||||||
|
`Secure` cookie flags must still be set by the application so
|
||||||
|
that the browser enforces HTTPS end-to-end.
|
||||||
|
|
||||||
|
This list is non-exhaustive. Apply defense-in-depth: if a standard
|
||||||
|
security hardening measure exists for HTTP services and is not
|
||||||
|
listed here, it is still expected. When in doubt, harden.
|
||||||
|
|
||||||
|
- `README.md` is the primary documentation. Required sections:
|
||||||
|
- **Description**: First line must include the project name,
|
||||||
|
purpose, category (web server, SPA, CLI tool, etc.), license, and
|
||||||
|
author. Example: "µPaaS is an MIT-licensed Go web application by
|
||||||
|
@sneak that receives git-frontend webhooks and deploys
|
||||||
|
applications via Docker in realtime."
|
||||||
|
- **Getting Started**: Copy-pasteable install/usage code block.
|
||||||
|
- **Entrypoints**: Opens by stating that the repo adheres to the
|
||||||
|
[Scripts to Rule Them All](https://github.com/github/scripts-to-rule-them-all)
|
||||||
|
standard (with that link), then documents each provided `script/`
|
||||||
|
entrypoint and its purpose.
|
||||||
|
- **Rationale**: Why does this exist?
|
||||||
|
- **Design**: How is the program structured?
|
||||||
|
- **TODO**: Update meticulously, even between commits. When
|
||||||
|
planning, put the todo list in the README so a new agent can pick
|
||||||
|
up where the last one left off.
|
||||||
|
- **License**: MIT, GPL, or WTFPL. Ask the user for new projects.
|
||||||
|
Include a `LICENSE` file in the repo root and a License section in
|
||||||
|
the README.
|
||||||
|
- **Author**: [@sneak](https://sneak.berlin).
|
||||||
|
|
||||||
|
- First commit of a new repo should contain only `README.md`.
|
||||||
|
|
||||||
|
- Go module root: `sneak.berlin/go/<name>`. Always run `go mod tidy`
|
||||||
|
before committing.
|
||||||
|
|
||||||
|
- Use SemVer.
|
||||||
|
|
||||||
|
- Database migrations live in `internal/db/migrations/` and must be
|
||||||
|
embedded in the binary.
|
||||||
|
- `000_migration.sql` — contains ONLY the creation of the migrations
|
||||||
|
tracking table itself. Nothing else.
|
||||||
|
- `001_schema.sql` — the full application schema.
|
||||||
|
- **Pre-1.0.0:** never add additional migration files (002, 003,
|
||||||
|
etc.). There is no installed base to migrate. Edit
|
||||||
|
`001_schema.sql` directly.
|
||||||
|
- **Post-1.0.0:** add new numbered migration files for each schema
|
||||||
|
change. Never edit existing migrations after release.
|
||||||
|
|
||||||
|
- All repos should have an `.editorconfig` enforcing the project's
|
||||||
|
indentation settings.
|
||||||
|
|
||||||
|
- Avoid putting files in the repo root unless necessary. Root should
|
||||||
|
contain only project-level config files (`Makefile`, `Dockerfile`,
|
||||||
|
`.gitignore`, `.editorconfig`, and language-specific config) plus
|
||||||
|
exactly three documentation files: `README.md`, `AGENTS.md` and
|
||||||
|
`LICENSE`. Every other markdown file — `REPO_POLICIES.md`, `TODO.md`,
|
||||||
|
design notes, runbooks — lives under `docs/`. Everything else goes in
|
||||||
|
a subdirectory. Canonical subdirectory names:
|
||||||
|
- `bin/` — executable scripts and tools
|
||||||
|
- `cmd/` — Go command entrypoints
|
||||||
|
- `configs/` — configuration templates and examples
|
||||||
|
- `deploy/` — deployment manifests (k8s, compose, terraform)
|
||||||
|
- `docs/` — all markdown except the three root files above
|
||||||
|
- `internal/` — Go internal packages
|
||||||
|
- `internal/db/migrations/` — database migrations
|
||||||
|
- `pkg/` — Go library packages
|
||||||
|
- `share/` — systemd units, data files
|
||||||
|
- `static/` — static assets (images, fonts, etc.)
|
||||||
|
- `web/` — web frontend source
|
||||||
|
|
||||||
|
- When setting up a new repo, files from the `prompts` repo may be used
|
||||||
|
as templates. Fetch them from
|
||||||
|
`https://git.eeqj.de/sneak/prompts/raw/branch/main/<path>`.
|
||||||
|
|
||||||
|
- New repos must contain at minimum:
|
||||||
|
- `README.md`, `AGENTS.md`, `.git`, `.gitignore`, `.editorconfig`
|
||||||
|
- `LICENSE`, `docs/REPO_POLICIES.md` (copy from the `prompts` repo)
|
||||||
|
- `Makefile`
|
||||||
|
- `script/` entrypoints (`bootstrap`, `setup`, `projectname`,
|
||||||
|
`test`, `lint`, `fmt`, `fmt-check`, `check`, `docker`, `cibuild`,
|
||||||
|
`precommit`, `install-precommit`)
|
||||||
|
- `Dockerfile`, `.dockerignore`
|
||||||
|
- `.gitea/workflows/check.yml`
|
||||||
|
- Go: `go.mod`, `go.sum`, `.golangci.yml`
|
||||||
|
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
|
||||||
|
- Python: `pyproject.toml`
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
# Workflow
|
||||||
|
|
||||||
|
- branch (from `main`)
|
||||||
|
- do the work in Next Step
|
||||||
|
- move Next Step to the top of Completed Steps
|
||||||
|
- move the top item of Future Steps into Next Step
|
||||||
|
- commit (`docs/TODO.md` changes in the same commit as the work)
|
||||||
|
- merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
|
- push
|
||||||
|
|
||||||
|
# Status
|
||||||
|
|
||||||
|
pre-1.0. No git tags exist. `main` is a working HTTP service that
|
||||||
|
builds, tests and lints clean: cobra command tree, `fx` object graph,
|
||||||
|
viper configuration that aborts on an unparseable value, sqlite with an
|
||||||
|
embedded schema and a migration runner, embedded templates and static
|
||||||
|
assets, the full middleware chain (request id, logging, metrics, panic
|
||||||
|
recovery, request timeout, body cap, security headers, CSRF), Sentry,
|
||||||
|
and Prometheus `/metrics` behind optional basic auth.
|
||||||
|
|
||||||
|
This repository is a template. A project seeded from it should replace
|
||||||
|
this Status section and everything below it, keeping the Workflow
|
||||||
|
section above unchanged.
|
||||||
|
|
||||||
|
# Next Step
|
||||||
|
|
||||||
|
Replace `gomodguard` with `gomodguard_v2` in `.golangci.yml`.
|
||||||
|
golangci-lint v2.12.2 emits a deprecation warning for it on every run
|
||||||
|
(`the linter 'gomodguard' is deprecated (since v2.12.0) ... Replaced by gomodguard_v2`).
|
||||||
|
Neither is configured with rules here, so the change is to the `linters`
|
||||||
|
block only; done when `make lint` runs clean with no deprecation warning
|
||||||
|
in the output.
|
||||||
|
|
||||||
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-08-22 Built the template out from an empty repository: STRTA
|
||||||
|
`script/` entrypoints with `Makefile` shims, `Dockerfile` and
|
||||||
|
`Dockerfile.lint` on digest-pinned bases, Gitea workflow running
|
||||||
|
`script/cibuild`, `.golangci.yml`, `.editorconfig`, `.prettierrc`,
|
||||||
|
`docs/REPO_POLICIES.md`, `AGENTS.md`, the HTTP service and its tests,
|
||||||
|
and `script/rename` for seeding
|
||||||
|
|
||||||
|
# Future Steps
|
||||||
|
|
||||||
|
- Add a `docker-compose.yml` showing the service behind a
|
||||||
|
TLS-terminating reverse proxy with `X-Forwarded-Proto` set, since that
|
||||||
|
is the deployment shape the CSRF middleware is written for and the one
|
||||||
|
an operator is most likely to get wrong
|
||||||
|
- Add a `script/release` that tags, builds with `VERSION` set, and
|
||||||
|
pushes the image, so `globals.Version` is something other than `dev`
|
||||||
|
in a real deployment
|
||||||
|
- Decide whether the template should ship a session/auth layer or stay
|
||||||
|
deliberately without one
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
module sneak.berlin/go/simplexcalc
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/dustin/go-humanize v1.0.1
|
||||||
|
github.com/getsentry/sentry-go v0.48.0
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/gorilla/csrf v1.7.3
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
|
github.com/spf13/cobra v1.10.2
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
go.uber.org/fx v1.24.0
|
||||||
|
modernc.org/sqlite v1.56.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
|
github.com/prometheus/common v0.66.1 // indirect
|
||||||
|
github.com/prometheus/procfs v0.16.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.uber.org/dig v1.19.0 // indirect
|
||||||
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
|
go.uber.org/zap v1.26.0 // indirect
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.37.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.8 // indirect
|
||||||
|
modernc.org/libc v1.74.4 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/getsentry/sentry-go v0.48.0 h1:FRZNr7Uk1C86ev1bSJmYlUkL9oyivQA6YOcdYfaaMmY=
|
||||||
|
github.com/getsentry/sentry-go v0.48.0/go.mod h1:E5UkA5wp1qR2+MDydNYlVeUiNN2xEdjYMidkgf0Qoss=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||||
|
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||||
|
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||||
|
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||||
|
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
||||||
|
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
||||||
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||||
|
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||||
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||||
|
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||||
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
|
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||||
|
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||||
|
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||||
|
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||||
|
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||||
|
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
|
||||||
|
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
|
||||||
|
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
|
||||||
|
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||||
|
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
|
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
|
||||||
|
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||||
|
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||||
|
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||||
|
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
|
||||||
|
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
|
||||||
|
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
|
||||||
|
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||||
|
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||||
|
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
|
||||||
|
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||||
|
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.56.0 h1:/D8e2RfFqoy/Zc6PuC76U28zFwmI/sYx1Kjm4yEn9e0=
|
||||||
|
modernc.org/sqlite v1.56.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// Package app wires the object graph. It is the one place that knows
|
||||||
|
// which concrete types satisfy the application's dependencies, so every
|
||||||
|
// other package can be constructed in a test with substitutes.
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"go.uber.org/fx/fxevent"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/database"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/handlers"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/middleware"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/render"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/server"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Module is every provider the service needs. Constructor order is
|
||||||
|
// irrelevant to fx; the order here is the order a reader wants: build
|
||||||
|
// metadata, logging, configuration, storage, then the HTTP layer.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // an fx module is a declaration, not mutable state.
|
||||||
|
var Module = fx.Options(
|
||||||
|
fx.Provide(
|
||||||
|
globals.New,
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
database.New,
|
||||||
|
telemetry.NewSentry,
|
||||||
|
telemetry.NewMetrics,
|
||||||
|
render.New,
|
||||||
|
middleware.New,
|
||||||
|
handlers.New,
|
||||||
|
server.New,
|
||||||
|
),
|
||||||
|
|
||||||
|
// fx's own lifecycle events go through the application logger, so
|
||||||
|
// the process emits one stream in one format. Without this, fx
|
||||||
|
// prints its own plain-text output to stderr and a log pipeline
|
||||||
|
// gets two formats from one process.
|
||||||
|
fx.WithLogger(func(l *logger.Logger) fxevent.Logger {
|
||||||
|
return &fxevent.SlogLogger{Logger: l.Get()}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Invoke forces the graph to be built. fx constructs lazily: without a
|
||||||
|
// request for the Server, a perfectly valid App would start, construct
|
||||||
|
// nothing, and serve nothing.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // as above.
|
||||||
|
var Invoke = fx.Invoke(func(_ *server.Server, log *logger.Logger, cfg *config.Config) {
|
||||||
|
if cfg.Debug {
|
||||||
|
log.EnableDebugLogging()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Identify()
|
||||||
|
})
|
||||||
|
|
||||||
|
// New builds the fx application for `serve`.
|
||||||
|
func New(opts ...fx.Option) *fx.App {
|
||||||
|
return fx.New(append([]fx.Option{Module, Invoke}, opts...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DiscardLogger is a logger that writes nothing, for tests that build
|
||||||
|
// the graph and do not want its startup output.
|
||||||
|
func DiscardLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
@@ -0,0 +1,390 @@
|
|||||||
|
// Package config loads runtime configuration from the environment (and
|
||||||
|
// an optional ./.env file) via viper.
|
||||||
|
//
|
||||||
|
// The iron rule of this package: a value that is SET but cannot be
|
||||||
|
// parsed aborts startup. It is never replaced by the default. An
|
||||||
|
// operator who writes PORT=eighty has said something specific and
|
||||||
|
// wrong, and starting anyway on port 8080 turns their mistake into a
|
||||||
|
// silent misconfiguration that only surfaces much later, somewhere
|
||||||
|
// else. Defaults apply to values that are ABSENT, and to nothing else.
|
||||||
|
//
|
||||||
|
// Every parse failure found in one pass is reported together, so a
|
||||||
|
// broken deployment takes one restart to diagnose rather than five.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
|
||||||
|
// spooky action at a distance!
|
||||||
|
// this populates the environment
|
||||||
|
// from a ./.env file automatically
|
||||||
|
// for development configuration.
|
||||||
|
// .env contents should be things like
|
||||||
|
// `PORT=8080`
|
||||||
|
// (without the backticks, of course)
|
||||||
|
_ "github.com/joho/godotenv/autoload"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Environment variable names. Bare names, no prefix: this matches the
|
||||||
|
// other services and keeps a compose file readable.
|
||||||
|
const (
|
||||||
|
EnvPort = "PORT"
|
||||||
|
EnvDataDir = "DATA_DIR"
|
||||||
|
EnvDBPath = "DB_PATH"
|
||||||
|
EnvDebug = "DEBUG"
|
||||||
|
EnvHSTS = "HSTS"
|
||||||
|
EnvBaseURL = "BASE_URL"
|
||||||
|
EnvMaxRequestBody = "MAX_REQUEST_BODY"
|
||||||
|
EnvRequestTimeout = "REQUEST_TIMEOUT"
|
||||||
|
EnvShutdownGrace = "SHUTDOWN_GRACE"
|
||||||
|
EnvSentryDSN = "SENTRY_DSN"
|
||||||
|
EnvSentryEnv = "SENTRY_ENVIRONMENT"
|
||||||
|
EnvMetricsUser = "METRICS_USER"
|
||||||
|
EnvMetricsPassword = "METRICS_PASSWORD"
|
||||||
|
EnvCSRFKey = "CSRF_KEY"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults for values that are absent. A value that is present and
|
||||||
|
// unparseable never reaches these.
|
||||||
|
const (
|
||||||
|
DefaultPort int64 = 8080
|
||||||
|
DefaultDataDir = "./data"
|
||||||
|
DefaultBaseURL = "http://localhost:8080"
|
||||||
|
DefaultMaxRequestBody int64 = 1 << 20 // 1 MiB
|
||||||
|
DefaultRequestTimeout = 30 * time.Second
|
||||||
|
DefaultShutdownGrace = 15 * time.Second
|
||||||
|
DefaultSentryEnv = "development"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bounds. A value inside the type but outside the range is as
|
||||||
|
// misconfigured as one that does not parse, and fails the same way.
|
||||||
|
const (
|
||||||
|
minPort int64 = 1
|
||||||
|
maxPort int64 = 65535
|
||||||
|
|
||||||
|
// minRequestBody is a floor below which no useful form submission
|
||||||
|
// fits; maxRequestBody is a ceiling above which the cap is not
|
||||||
|
// doing its job.
|
||||||
|
minRequestBody int64 = 1 << 10 // 1 KiB
|
||||||
|
maxRequestBody int64 = 1 << 26 // 64 MiB
|
||||||
|
|
||||||
|
minTimeout = 1 * time.Second
|
||||||
|
maxTimeout = 10 * time.Minute
|
||||||
|
|
||||||
|
// csrfKeyBytes is what gorilla/csrf requires: exactly 32 bytes,
|
||||||
|
// supplied as csrfKeyHexChars hex characters.
|
||||||
|
csrfKeyBytes = 32
|
||||||
|
csrfKeyHexChars = csrfKeyBytes * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInvalidConfig is the sentinel every configuration failure wraps,
|
||||||
|
// so callers can distinguish "the operator got it wrong" from "the
|
||||||
|
// machine is broken" without string matching.
|
||||||
|
var ErrInvalidConfig = errors.New("invalid configuration")
|
||||||
|
|
||||||
|
// Config is the parsed, validated runtime configuration. Every field
|
||||||
|
// is final by the time New returns: nothing re-reads the environment
|
||||||
|
// later, so there is exactly one moment at which configuration can be
|
||||||
|
// wrong, and it is before the listener opens.
|
||||||
|
type Config struct {
|
||||||
|
Port int
|
||||||
|
DataDir string
|
||||||
|
DBPath string
|
||||||
|
Debug bool
|
||||||
|
HSTS bool
|
||||||
|
BaseURL string
|
||||||
|
MaxRequestBody int64
|
||||||
|
RequestTimeout time.Duration
|
||||||
|
ShutdownGrace time.Duration
|
||||||
|
|
||||||
|
SentryDSN string
|
||||||
|
SentryEnvironment string
|
||||||
|
|
||||||
|
// MetricsUser and MetricsPassword gate /metrics. Both set or
|
||||||
|
// neither: half-set is refused rather than resolved, because
|
||||||
|
// either resolution is dangerous. Treating a missing password as
|
||||||
|
// empty would publish the metrics endpoint to anyone who guesses
|
||||||
|
// the username; treating a missing username as "no auth" would
|
||||||
|
// publish it to everyone, in a deployment whose operator plainly
|
||||||
|
// intended it to be closed.
|
||||||
|
MetricsUser string
|
||||||
|
MetricsPassword string
|
||||||
|
|
||||||
|
// CSRFKey is exactly 32 bytes. When CSRF_KEY is absent, a random
|
||||||
|
// key is generated at startup and a warning is logged: tokens then
|
||||||
|
// do not survive a restart, which is fine in development and not
|
||||||
|
// fine behind more than one replica. Absent is a default; present
|
||||||
|
// and malformed is a startup failure.
|
||||||
|
CSRFKey []byte
|
||||||
|
CSRFKeyEphemeral bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params defines dependencies for Config.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
}
|
||||||
|
|
||||||
|
// loader parses one environment into a Config, accumulating every
|
||||||
|
// failure instead of stopping at the first, so one restart surfaces the
|
||||||
|
// whole list.
|
||||||
|
type loader struct {
|
||||||
|
v *viper.Viper
|
||||||
|
errs []error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) fail(key, raw, why string) {
|
||||||
|
l.errs = append(l.errs, fmt.Errorf(
|
||||||
|
"%w: %s=%q is %s", ErrInvalidConfig, key, raw, why,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
// raw returns the trimmed value of key, and whether it was set to
|
||||||
|
// anything. Whitespace-only counts as absent: it is what an empty
|
||||||
|
// compose-file entry produces, and no key here has a meaningful blank
|
||||||
|
// value.
|
||||||
|
func (l *loader) raw(key string) (string, bool) {
|
||||||
|
s := strings.TrimSpace(l.v.GetString(key))
|
||||||
|
|
||||||
|
return s, s != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) str(key, def string) string {
|
||||||
|
if s, ok := l.raw(key); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) integer(key string, def, minVal, maxVal int64) int64 {
|
||||||
|
s, ok := l.raw(key)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := strconv.ParseInt(s, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
l.fail(key, s, "not an integer")
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
if n < minVal || n > maxVal {
|
||||||
|
l.fail(key, s, fmt.Sprintf("outside the range %d..%d", minVal, maxVal))
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolean accepts what strconv.ParseBool accepts (1/t/T/TRUE/true/True
|
||||||
|
// and the false equivalents) and refuses everything else. "yes" is a
|
||||||
|
// parse failure on purpose: guessing at it is how a security header
|
||||||
|
// ends up off in production.
|
||||||
|
func (l *loader) boolean(key string, def bool) bool {
|
||||||
|
s, ok := l.raw(key)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
b, err := strconv.ParseBool(s)
|
||||||
|
if err != nil {
|
||||||
|
l.fail(key, s, "not a boolean (use true or false)")
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *loader) duration(key string, def time.Duration) time.Duration {
|
||||||
|
s, ok := l.raw(key)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
d, err := time.ParseDuration(s)
|
||||||
|
if err != nil {
|
||||||
|
l.fail(key, s, "not a duration (e.g. 30s, 2m)")
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
if d < minTimeout || d > maxTimeout {
|
||||||
|
l.fail(key, s, fmt.Sprintf("outside the range %s..%s", minTimeout, maxTimeout))
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// bytesize accepts both a plain integer and a human size ("1MiB",
|
||||||
|
// "512kB"), which is the form an operator actually writes.
|
||||||
|
func (l *loader) bytesize(key string, def, minVal, maxVal int64) int64 {
|
||||||
|
s, ok := l.raw(key)
|
||||||
|
if !ok {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := humanize.ParseBytes(s)
|
||||||
|
if err != nil {
|
||||||
|
l.fail(key, s, "not a byte size (e.g. 1048576, 1MiB, 512kB)")
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxVal and minVal are compile-time constants of this package,
|
||||||
|
// both positive, so these conversions cannot overflow; n is
|
||||||
|
// range-checked before it is narrowed.
|
||||||
|
if n > uint64(maxVal) { //nolint:gosec // see above
|
||||||
|
l.fail(key, s, byteRangeMessage(minVal, maxVal))
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
sz := int64(n) //nolint:gosec // n was just checked against maxVal, a positive int64.
|
||||||
|
if sz < minVal {
|
||||||
|
l.fail(key, s, byteRangeMessage(minVal, maxVal))
|
||||||
|
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
return sz
|
||||||
|
}
|
||||||
|
|
||||||
|
// byteRangeMessage renders the permitted size range the way an operator
|
||||||
|
// wrote the value they got wrong.
|
||||||
|
func byteRangeMessage(minVal, maxVal int64) string {
|
||||||
|
//nolint:gosec // both are positive compile-time constants of this package.
|
||||||
|
return fmt.Sprintf("outside the range %s..%s",
|
||||||
|
humanize.IBytes(uint64(minVal)), humanize.IBytes(uint64(maxVal)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// New parses and validates the environment. Returning an error here
|
||||||
|
// aborts fx startup before anything listens, which is the whole point:
|
||||||
|
// there is no partially configured running state to reason about.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
|
func New(lc fx.Lifecycle, _ Params) (*Config, error) {
|
||||||
|
v := viper.New()
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
return load(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// load is New's body against an explicit viper instance, so tests can
|
||||||
|
// drive it with a known environment instead of mutating the process's.
|
||||||
|
func load(v *viper.Viper) (*Config, error) {
|
||||||
|
l := &loader{v: v}
|
||||||
|
|
||||||
|
c := &Config{}
|
||||||
|
|
||||||
|
c.Port = int(l.integer(EnvPort, DefaultPort, minPort, maxPort))
|
||||||
|
c.DataDir = l.str(EnvDataDir, DefaultDataDir)
|
||||||
|
c.Debug = l.boolean(EnvDebug, false)
|
||||||
|
c.BaseURL = l.str(EnvBaseURL, DefaultBaseURL)
|
||||||
|
|
||||||
|
// HSTS defaults to on unless debugging: pinning a developer's
|
||||||
|
// browser to HTTPS on localhost is a self-inflicted outage that
|
||||||
|
// outlives the process.
|
||||||
|
c.HSTS = l.boolean(EnvHSTS, !c.Debug)
|
||||||
|
|
||||||
|
c.DBPath = l.str(EnvDBPath, filepath.Join(c.DataDir, "simplexcalc.db"))
|
||||||
|
c.MaxRequestBody = l.bytesize(
|
||||||
|
EnvMaxRequestBody, DefaultMaxRequestBody, minRequestBody, maxRequestBody,
|
||||||
|
)
|
||||||
|
c.RequestTimeout = l.duration(EnvRequestTimeout, DefaultRequestTimeout)
|
||||||
|
c.ShutdownGrace = l.duration(EnvShutdownGrace, DefaultShutdownGrace)
|
||||||
|
|
||||||
|
c.SentryDSN = l.str(EnvSentryDSN, "")
|
||||||
|
c.SentryEnvironment = l.str(EnvSentryEnv, DefaultSentryEnv)
|
||||||
|
l.checkSentryDSN(c.SentryDSN)
|
||||||
|
|
||||||
|
c.MetricsUser = l.str(EnvMetricsUser, "")
|
||||||
|
c.MetricsPassword = l.str(EnvMetricsPassword, "")
|
||||||
|
l.checkMetricsAuth(c)
|
||||||
|
|
||||||
|
l.loadCSRFKey(c)
|
||||||
|
|
||||||
|
if len(l.errs) > 0 {
|
||||||
|
return nil, errors.Join(l.errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSentryDSN refuses a DSN that is present and not a URL. An empty
|
||||||
|
// DSN disables Sentry and is not an error; a typo'd one that silently
|
||||||
|
// disabled it would be, since the operator would believe errors were
|
||||||
|
// being reported.
|
||||||
|
func (l *loader) checkSentryDSN(dsn string) {
|
||||||
|
if dsn == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(dsn)
|
||||||
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||||
|
// The DSN embeds a key; report the failure without it.
|
||||||
|
l.errs = append(l.errs, fmt.Errorf(
|
||||||
|
"%w: %s is set but is not a valid DSN URL", ErrInvalidConfig, EnvSentryDSN,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMetricsAuth refuses a half-configured metrics credential. See
|
||||||
|
// the field comment on Config.MetricsUser for why neither resolution
|
||||||
|
// is acceptable.
|
||||||
|
func (l *loader) checkMetricsAuth(c *Config) {
|
||||||
|
switch {
|
||||||
|
case c.MetricsUser == "" && c.MetricsPassword == "":
|
||||||
|
return
|
||||||
|
case c.MetricsUser == "":
|
||||||
|
l.errs = append(l.errs, fmt.Errorf(
|
||||||
|
"%w: %s is set but %s is not; set both or neither",
|
||||||
|
ErrInvalidConfig, EnvMetricsPassword, EnvMetricsUser,
|
||||||
|
))
|
||||||
|
case c.MetricsPassword == "":
|
||||||
|
l.errs = append(l.errs, fmt.Errorf(
|
||||||
|
"%w: %s is set but %s is not; set both or neither",
|
||||||
|
ErrInvalidConfig, EnvMetricsUser, EnvMetricsPassword,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadCSRFKey decodes CSRF_KEY, or marks the config for an ephemeral
|
||||||
|
// key. Generating the random key is deferred to the server, so that
|
||||||
|
// this function stays pure and testable.
|
||||||
|
func (l *loader) loadCSRFKey(c *Config) {
|
||||||
|
s, ok := l.raw(EnvCSRFKey)
|
||||||
|
if !ok {
|
||||||
|
c.CSRFKeyEphemeral = true
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
key, err := decodeHex(s)
|
||||||
|
if err != nil {
|
||||||
|
// The value is a secret: say what is wrong with it, never
|
||||||
|
// quote it.
|
||||||
|
l.errs = append(l.errs, fmt.Errorf(
|
||||||
|
"%w: %s is set but is not %d hex characters",
|
||||||
|
ErrInvalidConfig, EnvCSRFKey, csrfKeyHexChars,
|
||||||
|
))
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.CSRFKey = key
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Credentials used by the metrics-auth cases.
|
||||||
|
const (
|
||||||
|
testUser = "scraper"
|
||||||
|
testPass = "hunter2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// env builds a viper instance holding exactly the given keys, so a test
|
||||||
|
// describes one environment without touching the process's.
|
||||||
|
func env(kv map[string]string) *viper.Viper {
|
||||||
|
v := viper.New()
|
||||||
|
for k, val := range kv {
|
||||||
|
v.Set(k, val)
|
||||||
|
}
|
||||||
|
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAbsentValuesTakeDefaults pins the other half of the iron rule: a
|
||||||
|
// value that is not set does get the default. Without this, a bug that
|
||||||
|
// rejected everything would pass every test below.
|
||||||
|
func TestAbsentValuesTakeDefaults(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := config.Load(env(nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty environment must be valid, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != int(config.DefaultPort) {
|
||||||
|
t.Errorf("Port = %d, want %d", c.Port, config.DefaultPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxRequestBody != config.DefaultMaxRequestBody {
|
||||||
|
t.Errorf("MaxRequestBody = %d, want %d",
|
||||||
|
c.MaxRequestBody, config.DefaultMaxRequestBody)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.RequestTimeout != config.DefaultRequestTimeout {
|
||||||
|
t.Errorf("RequestTimeout = %s, want %s",
|
||||||
|
c.RequestTimeout, config.DefaultRequestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.HSTS {
|
||||||
|
t.Error("HSTS must default on when DEBUG is not set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !c.CSRFKeyEphemeral {
|
||||||
|
t.Error("an absent CSRF_KEY must mark the config for an ephemeral key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetButUnparseableAborts is the central contract of this package.
|
||||||
|
// Every case is a value an operator plausibly types, and every one of
|
||||||
|
// them must fail startup rather than be replaced by the default.
|
||||||
|
func TestSetButUnparseableAborts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]map[string]string{
|
||||||
|
"port is not a number": {config.EnvPort: "eighty"},
|
||||||
|
"port is zero": {config.EnvPort: "0"},
|
||||||
|
"port is above the range": {config.EnvPort: "70000"},
|
||||||
|
"port is a float": {config.EnvPort: "8080.0"},
|
||||||
|
"debug is yes": {config.EnvDebug: "yes"},
|
||||||
|
"hsts is on": {config.EnvHSTS: "on"},
|
||||||
|
"body cap is nonsense": {config.EnvMaxRequestBody: "big"},
|
||||||
|
"body cap is too large": {config.EnvMaxRequestBody: "1TiB"},
|
||||||
|
"body cap is too small": {config.EnvMaxRequestBody: "10"},
|
||||||
|
"timeout has no unit": {config.EnvRequestTimeout: "30"},
|
||||||
|
"timeout is out of range": {config.EnvRequestTimeout: "1h"},
|
||||||
|
"grace is nonsense": {config.EnvShutdownGrace: "soon"},
|
||||||
|
"sentry dsn is not a url": {config.EnvSentryDSN: "not a dsn"},
|
||||||
|
"csrf key is not hex": {config.EnvCSRFKey: "not-hex-at-all"},
|
||||||
|
"csrf key is wrong length": {config.EnvCSRFKey: "abcdef"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, kv := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := config.Load(env(kv))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("wanted a startup failure, got a Config: %+v", c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||||
|
t.Errorf("error does not wrap ErrInvalidConfig: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c != nil {
|
||||||
|
t.Error("a failed load must return no Config at all")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecretsAreNotEchoed: a rejected CSRF key must not appear in the
|
||||||
|
// error, because errors are logged and a log is not a place to put a
|
||||||
|
// key.
|
||||||
|
func TestSecretsAreNotEchoed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const secret = "00112233445566778899aabbccdd" // valid hex, wrong length
|
||||||
|
|
||||||
|
_, err := config.Load(env(map[string]string{config.EnvCSRFKey: secret}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("wanted a failure for a short CSRF key")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(err.Error(), secret) {
|
||||||
|
t.Errorf("the rejected key was echoed in the error: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHalfSetMetricsAuthAborts covers the case the issue calls out
|
||||||
|
// explicitly: auth config that is half-set must fail loudly, in both
|
||||||
|
// directions.
|
||||||
|
func TestHalfSetMetricsAuthAborts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cases := map[string]map[string]string{
|
||||||
|
"user without password": {config.EnvMetricsUser: testUser},
|
||||||
|
"password without user": {config.EnvMetricsPassword: testPass},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, kv := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.Load(env(kv))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("half-set metrics credentials must abort startup")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !errors.Is(err, config.ErrInvalidConfig) {
|
||||||
|
t.Errorf("error does not wrap ErrInvalidConfig: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
both, err := config.Load(env(map[string]string{
|
||||||
|
config.EnvMetricsUser: testUser, config.EnvMetricsPassword: testPass,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("both credentials set must be valid, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if both.MetricsUser != testUser || both.MetricsPassword != testPass {
|
||||||
|
t.Error("credentials did not survive parsing")
|
||||||
|
}
|
||||||
|
|
||||||
|
neither, err := config.Load(env(nil))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("neither credential set must be valid, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if neither.MetricsUser != "" || neither.MetricsPassword != "" {
|
||||||
|
t.Error("credentials appeared from nowhere")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEveryFailureIsReported: one restart should surface the whole list,
|
||||||
|
// not just the first problem.
|
||||||
|
func TestEveryFailureIsReported(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
_, err := config.Load(env(map[string]string{
|
||||||
|
config.EnvPort: "eighty",
|
||||||
|
config.EnvDebug: "yes",
|
||||||
|
config.EnvRequestTimeout: "soon",
|
||||||
|
}))
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("wanted failures")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
config.EnvPort, config.EnvDebug, config.EnvRequestTimeout,
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), key) {
|
||||||
|
t.Errorf("%s is broken but is not named in the error: %v", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestValidValuesAreUsed proves the parsers accept what they document.
|
||||||
|
func TestValidValuesAreUsed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const key = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
c, err := config.Load(env(map[string]string{
|
||||||
|
config.EnvPort: "9000",
|
||||||
|
config.EnvDebug: "true",
|
||||||
|
config.EnvMaxRequestBody: "2MiB",
|
||||||
|
config.EnvRequestTimeout: "45s",
|
||||||
|
config.EnvShutdownGrace: "5s",
|
||||||
|
config.EnvDataDir: "/var/lib/example",
|
||||||
|
config.EnvCSRFKey: key,
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid environment was rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.Port != 9000 {
|
||||||
|
t.Errorf("Port = %d, want 9000", c.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.MaxRequestBody != 2<<20 {
|
||||||
|
t.Errorf("MaxRequestBody = %d, want %d", c.MaxRequestBody, 2<<20)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.RequestTimeout != 45*time.Second {
|
||||||
|
t.Errorf("RequestTimeout = %s, want 45s", c.RequestTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.HSTS {
|
||||||
|
t.Error("HSTS must default off when DEBUG is true")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.CSRFKey) != config.CSRFKeyBytes || c.CSRFKeyEphemeral {
|
||||||
|
t.Errorf("CSRFKey not decoded: len=%d ephemeral=%v",
|
||||||
|
len(c.CSRFKey), c.CSRFKeyEphemeral)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DBPath != "/var/lib/example/simplexcalc.db" {
|
||||||
|
t.Errorf("DBPath = %q, want it derived from DATA_DIR", c.DBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExplicitOverridesDerivedDBPath: DB_PATH wins over the DATA_DIR
|
||||||
|
// derivation, which is the only reason it exists.
|
||||||
|
func TestExplicitOverridesDerivedDBPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c, err := config.Load(env(map[string]string{
|
||||||
|
config.EnvDataDir: "/var/lib/example",
|
||||||
|
config.EnvDBPath: "/srv/other.db",
|
||||||
|
}))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid environment was rejected: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.DBPath != "/srv/other.db" {
|
||||||
|
t.Errorf("DBPath = %q, want /srv/other.db", c.DBPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// Load is load, exported for the external test package.
|
||||||
|
//
|
||||||
|
// The tests live in config_test rather than config so that they drive
|
||||||
|
// this package the way the rest of the program does — through its
|
||||||
|
// exported surface — and cannot quietly depend on an internal detail.
|
||||||
|
// The one thing they need that is not exported is the ability to
|
||||||
|
// supply an environment instead of reading the process's, which is a
|
||||||
|
// testing seam and not API.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a test seam, not mutable state.
|
||||||
|
var Load = load
|
||||||
|
|
||||||
|
// CSRFKeyBytes is the required key length, so the tests can assert on
|
||||||
|
// it without restating the number.
|
||||||
|
const CSRFKeyBytes = csrfKeyBytes
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errKeyLength is returned for a well-formed hex string of the wrong
|
||||||
|
// length, so decodeHex has one error type for both ways of being wrong.
|
||||||
|
var errKeyLength = errors.New("wrong key length")
|
||||||
|
|
||||||
|
// decodeHex decodes exactly csrfKeyBytes bytes of hex. It exists as its
|
||||||
|
// own function so that the length rule and the encoding rule are
|
||||||
|
// enforced in one place, and so that the caller never has to decide
|
||||||
|
// what a short-but-valid key means.
|
||||||
|
func decodeHex(s string) ([]byte, error) {
|
||||||
|
b, err := hex.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("decoding hex: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(b) != csrfKeyBytes {
|
||||||
|
return nil, fmt.Errorf("%w: got %d bytes, want %d",
|
||||||
|
errKeyLength, len(b), csrfKeyBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
// Package database owns the sqlite connection and the schema. The
|
||||||
|
// schema is embedded in the binary, so a deployment is one file: there
|
||||||
|
// is no migrations directory to ship alongside it and no version of it
|
||||||
|
// that can be out of step with the code that expects it.
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
|
||||||
|
// modernc.org/sqlite is the pure-Go driver: no cgo, so the binary
|
||||||
|
// links statically and the container needs no libc.
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
// schemaFS carries the migrations into the binary.
|
||||||
|
//
|
||||||
|
//go:embed schema/*.sql
|
||||||
|
var schemaFS embed.FS
|
||||||
|
|
||||||
|
// dirPerm is the mode for the data directory: owner-only, because it
|
||||||
|
// holds the database.
|
||||||
|
const dirPerm = 0o700
|
||||||
|
|
||||||
|
// pragmas are applied to every connection. WAL is what makes concurrent
|
||||||
|
// reads not block on a write; busy_timeout is what turns the remaining
|
||||||
|
// contention into a short wait rather than an immediate SQLITE_BUSY;
|
||||||
|
// foreign_keys is off by default in sqlite and has to be asked for.
|
||||||
|
const pragmas = `
|
||||||
|
PRAGMA journal_mode = WAL;
|
||||||
|
PRAGMA busy_timeout = 5000;
|
||||||
|
PRAGMA foreign_keys = ON;
|
||||||
|
PRAGMA synchronous = NORMAL;
|
||||||
|
`
|
||||||
|
|
||||||
|
// Params defines dependencies for Database.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Config *config.Config
|
||||||
|
Logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database is the handle to the application's sqlite database.
|
||||||
|
type Database struct {
|
||||||
|
db *sql.DB
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New opens the database, applies the embedded migrations, and
|
||||||
|
// registers a close hook. Migrations run during OnStart rather than
|
||||||
|
// lazily on first use: a schema that cannot be applied is a failure to
|
||||||
|
// start, and the process says so before it accepts a request.
|
||||||
|
func New(lc fx.Lifecycle, params Params) (*Database, error) {
|
||||||
|
d := &Database{log: params.Logger.Get()}
|
||||||
|
|
||||||
|
err := os.MkdirAll(filepath.Dir(params.Config.DBPath), dirPerm)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("creating data directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New runs during graph construction, which has no request or
|
||||||
|
// lifecycle context of its own; the pragmas are a handful of
|
||||||
|
// in-process statements against a file that was just created.
|
||||||
|
db, err := Open(context.Background(), params.Config.DBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.db = db
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: func(ctx context.Context) error {
|
||||||
|
return d.Migrate(ctx)
|
||||||
|
},
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
d.log.Info("closing database")
|
||||||
|
|
||||||
|
closeErr := d.db.Close()
|
||||||
|
if closeErr != nil {
|
||||||
|
return fmt.Errorf("closing database: %w", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open opens a sqlite database at path and applies the connection
|
||||||
|
// pragmas. Exported so tests can open a scratch database without the
|
||||||
|
// fx graph.
|
||||||
|
func Open(ctx context.Context, path string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("opening database %s: %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqlite tolerates exactly one writer. Holding the pool to a
|
||||||
|
// single connection makes that limit explicit here rather than
|
||||||
|
// intermittent under load, and WAL keeps readers off the writer's
|
||||||
|
// back anyway.
|
||||||
|
db.SetMaxOpenConns(1)
|
||||||
|
db.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, pragmas)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("applying pragmas: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewForTest opens a scratch database in dir and migrates it. Test
|
||||||
|
// helper, exported so that a project seeded from this template can use
|
||||||
|
// it from any package's tests.
|
||||||
|
func NewForTest(ctx context.Context, dir string) (*Database, error) {
|
||||||
|
d := &Database{log: slog.New(slog.DiscardHandler)}
|
||||||
|
|
||||||
|
db, err := Open(ctx, filepath.Join(dir, "test.db"))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.db = db
|
||||||
|
|
||||||
|
err = d.Migrate(ctx)
|
||||||
|
if err != nil {
|
||||||
|
_ = db.Close()
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migrate applies every embedded migration that has not been applied to
|
||||||
|
// this database yet.
|
||||||
|
func (d *Database) Migrate(ctx context.Context) error {
|
||||||
|
set := migrationSet{fsys: schemaFS, dir: "schema"}
|
||||||
|
|
||||||
|
err := set.apply(ctx, d.db, d.log)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("applying migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DB exposes the underlying handle for packages that need to query it.
|
||||||
|
func (d *Database) DB() *sql.DB {
|
||||||
|
return d.db
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppliedVersions returns the migration versions recorded as applied,
|
||||||
|
// ascending. The healthcheck reports the highest of them, so an
|
||||||
|
// operator can see which schema a running instance is on without
|
||||||
|
// shelling into it.
|
||||||
|
func (d *Database) AppliedVersions(ctx context.Context) ([]int, error) {
|
||||||
|
rows, err := d.db.QueryContext(ctx,
|
||||||
|
"SELECT version FROM schema_migrations ORDER BY version",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("reading applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
var versions []int
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var v int
|
||||||
|
|
||||||
|
scanErr := rows.Scan(&v)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("scanning migration version: %w", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
versions = append(versions, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("iterating applied migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return versions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases the handle. Production uses the fx OnStop hook; tests
|
||||||
|
// call this.
|
||||||
|
func (d *Database) Close() error {
|
||||||
|
err := d.db.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("closing database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package database_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// open returns a migrated scratch database in a directory the test
|
||||||
|
// framework removes afterwards.
|
||||||
|
func open(t *testing.T) *database.Database {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := database.NewForTest(t.Context(), t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening test database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
closeErr := db.Close()
|
||||||
|
if closeErr != nil {
|
||||||
|
t.Errorf("closing test database: %v", closeErr)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrationsApplyFromClean is the claim the healthcheck and the
|
||||||
|
// container both rest on: an empty directory becomes a usable schema
|
||||||
|
// with no operator step in between.
|
||||||
|
func TestMigrationsApplyFromClean(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := open(t)
|
||||||
|
|
||||||
|
versions, err := db.AppliedVersions(t.Context())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading applied versions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 000 (the ledger) and 001 (widgets), which is every file the
|
||||||
|
// schema directory currently embeds.
|
||||||
|
if len(versions) != 2 || versions[0] != 0 || versions[1] != 1 {
|
||||||
|
t.Fatalf("applied versions = %v, want [0 1]", versions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrationsAreIdempotent: a restart re-runs Migrate against a
|
||||||
|
// database that already has the schema, and must change nothing. A
|
||||||
|
// migration runner that fails here takes the service down on every
|
||||||
|
// second start.
|
||||||
|
func TestMigrationsAreIdempotent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
first, err := database.NewForTest(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("first open: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = first.CreateWidget(ctx, "survivor", 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating widget: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = first.Close()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("closing: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := database.NewForTest(ctx, dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reopening and re-migrating: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = second.Close() }()
|
||||||
|
|
||||||
|
versions, err := second.AppliedVersions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading applied versions: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) != 2 {
|
||||||
|
t.Errorf("re-running migrations changed the ledger: %v", versions)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The data has to still be there: a migration runner that "fixes"
|
||||||
|
// an already-migrated database by recreating tables is worse than
|
||||||
|
// one that fails.
|
||||||
|
count, err := second.CountWidgets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("counting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("widget count = %d after reopen, want 1", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWidgetRoundTrip exercises the query layer against the real
|
||||||
|
// schema, including the timestamp format shared between Go and the SQL
|
||||||
|
// DEFAULT.
|
||||||
|
func TestWidgetRoundTrip(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := open(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
created, err := db.CreateWidget(ctx, "widget one", 4096)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating widget: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if created.ID == "" {
|
||||||
|
t.Error("created widget has no id")
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets, err := db.ListWidgets(ctx, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listing widgets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(widgets) != 1 {
|
||||||
|
t.Fatalf("listed %d widgets, want 1", len(widgets))
|
||||||
|
}
|
||||||
|
|
||||||
|
got := widgets[0]
|
||||||
|
if got.ID != created.ID || got.Name != "widget one" || got.SizeBytes != 4096 {
|
||||||
|
t.Errorf("round trip lost data: %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got.CreatedAt.IsZero() {
|
||||||
|
t.Error("created_at did not survive the round trip")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListWidgetsRespectsLimit: the index query is bounded, and the
|
||||||
|
// bound has to actually bind.
|
||||||
|
func TestListWidgetsRespectsLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
db := open(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
|
||||||
|
for range 5 {
|
||||||
|
_, err := db.CreateWidget(ctx, "w", 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("creating widget: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets, err := db.ListWidgets(ctx, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listing widgets: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(widgets) != 2 {
|
||||||
|
t.Errorf("limit 2 returned %d rows", len(widgets))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseMigrationVersion covers the naming contract the schema
|
||||||
|
// directory has to keep. A file this rejects is a file that would
|
||||||
|
// otherwise be silently skipped.
|
||||||
|
func TestParseMigrationVersion(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
good := map[string]int{
|
||||||
|
"000.sql": 0,
|
||||||
|
"001_widgets.sql": 1,
|
||||||
|
"017_thing.sql": 17,
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, want := range good {
|
||||||
|
got, err := database.ParseMigrationVersion(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("%s: unexpected error %v", name, err)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s: version = %d, want %d", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{"widgets.sql", "_001.sql", "v1_widgets.sql"} {
|
||||||
|
_, err := database.ParseMigrationVersion(name)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("%s: wanted a rejection, got none", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOpenCreatesFile: Open must produce a database at the path it was
|
||||||
|
// given, not somewhere else.
|
||||||
|
func TestOpenCreatesFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
db, err := database.Open(t.Context(), filepath.Join(dir, "explicit.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("opening: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer func() { _ = db.Close() }()
|
||||||
|
|
||||||
|
err = db.PingContext(t.Context())
|
||||||
|
if err != nil && !errors.Is(err, context.Canceled) {
|
||||||
|
t.Errorf("pinging the opened database: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bootstrapVersion is 000.sql: the migration that creates the ledger
|
||||||
|
// the others are recorded in.
|
||||||
|
const bootstrapVersion = 0
|
||||||
|
|
||||||
|
// errBadMigrationName is returned for a schema file whose name does not
|
||||||
|
// start with a version number. It is a build-time mistake, not a
|
||||||
|
// runtime condition, and it fails startup rather than being skipped —
|
||||||
|
// a migration silently not applied is the failure mode this whole
|
||||||
|
// mechanism exists to prevent.
|
||||||
|
var errBadMigrationName = errors.New(
|
||||||
|
"migration filename does not start with a version number",
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseMigrationVersion extracts the leading integer from a migration
|
||||||
|
// filename: "001_widgets.sql" is version 1. Exported so that a project
|
||||||
|
// seeded from this template can validate its own schema directory in a
|
||||||
|
// test.
|
||||||
|
func ParseMigrationVersion(name string) (int, error) {
|
||||||
|
base := name
|
||||||
|
if i := strings.IndexAny(base, "_."); i > 0 {
|
||||||
|
base = base[:i]
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := strconv.Atoi(base)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w: %q", errBadMigrationName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return version, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationSet is one embedded directory of numbered .sql migrations
|
||||||
|
// (000 bootstrap plus schema files).
|
||||||
|
type migrationSet struct {
|
||||||
|
fsys fs.FS
|
||||||
|
dir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// collect returns the set's migration filenames sorted
|
||||||
|
// lexicographically, which is why they are zero-padded.
|
||||||
|
func (m migrationSet) collect() ([]string, error) {
|
||||||
|
entries, err := fs.ReadDir(m.fsys, m.dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to read schema directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var migrations []string
|
||||||
|
|
||||||
|
for _, entry := range entries {
|
||||||
|
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
|
migrations = append(migrations, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(migrations)
|
||||||
|
|
||||||
|
return migrations, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// bootstrap ensures the schema_migrations table exists by applying
|
||||||
|
// 000.sql if the table is missing.
|
||||||
|
func (m migrationSet) bootstrap(
|
||||||
|
ctx context.Context, db *sql.DB, log *slog.Logger,
|
||||||
|
) error {
|
||||||
|
var tableExists int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='schema_migrations'",
|
||||||
|
).Scan(&tableExists)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check for migrations table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tableExists > 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := fs.ReadFile(m.fsys, path.Join(m.dir, "000.sql"))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read bootstrap migration 000.sql: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("applying bootstrap migration", "version", bootstrapVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = db.ExecContext(ctx, string(content))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to apply bootstrap migration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applied reports whether the numbered migration has been recorded.
|
||||||
|
func (m migrationSet) applied(
|
||||||
|
ctx context.Context, db *sql.DB, version int,
|
||||||
|
) (bool, error) {
|
||||||
|
var count int
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM schema_migrations WHERE version = ?",
|
||||||
|
version,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to check migration status: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyOne reads, executes, and records one migration file.
|
||||||
|
func (m migrationSet) applyOne(
|
||||||
|
ctx context.Context, db *sql.DB, migration string, version int,
|
||||||
|
) error {
|
||||||
|
content, err := fs.ReadFile(m.fsys, path.Join(m.dir, migration))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read migration %s: %w", migration, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, execErr := db.ExecContext(ctx, string(content))
|
||||||
|
if execErr != nil {
|
||||||
|
return fmt.Errorf("failed to apply migration %s: %w", migration, execErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, recErr := db.ExecContext(ctx,
|
||||||
|
"INSERT INTO schema_migrations (version) VALUES (?)",
|
||||||
|
version,
|
||||||
|
)
|
||||||
|
if recErr != nil {
|
||||||
|
return fmt.Errorf("failed to record migration %s: %w", migration, recErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// apply runs all pending migrations of the set, in order. Idempotent:
|
||||||
|
// a second run over the same database applies nothing.
|
||||||
|
func (m migrationSet) apply(ctx context.Context, db *sql.DB, log *slog.Logger) error {
|
||||||
|
err := m.bootstrap(ctx, db, log)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations, err := m.collect()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, migration := range migrations {
|
||||||
|
version, parseErr := ParseMigrationVersion(migration)
|
||||||
|
if parseErr != nil {
|
||||||
|
return parseErr
|
||||||
|
}
|
||||||
|
|
||||||
|
done, checkErr := m.applied(ctx, db, version)
|
||||||
|
if checkErr != nil {
|
||||||
|
return checkErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if done {
|
||||||
|
if log != nil {
|
||||||
|
log.Debug("migration already applied", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("applying migration", "version", version)
|
||||||
|
}
|
||||||
|
|
||||||
|
applyErr := m.applyOne(ctx, db, migration, version)
|
||||||
|
if applyErr != nil {
|
||||||
|
return applyErr
|
||||||
|
}
|
||||||
|
|
||||||
|
if log != nil {
|
||||||
|
log.Info("migration applied successfully", "version", version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
// Go has no UUID in the standard library as of go1.25 — checked
|
||||||
|
// against this repo's toolchain, not assumed. Swap this import for
|
||||||
|
// the stdlib package the moment one lands; nothing else here
|
||||||
|
// depends on the implementation.
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// timeFormat matches the strftime pattern the schema uses for its
|
||||||
|
// defaults, so rows written by Go and rows written by a DEFAULT sort
|
||||||
|
// against each other correctly.
|
||||||
|
const timeFormat = "2006-01-02T15:04:05.000Z"
|
||||||
|
|
||||||
|
// Widget is the example row type. It exists so that the migration
|
||||||
|
// runner, the query layer, the templates and the tests all exercise
|
||||||
|
// real data. Delete it when seeding a real project.
|
||||||
|
type Widget struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
SizeBytes int64
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWidget inserts a widget and returns it as stored.
|
||||||
|
func (d *Database) CreateWidget(
|
||||||
|
ctx context.Context, name string, size int64,
|
||||||
|
) (*Widget, error) {
|
||||||
|
w := &Widget{
|
||||||
|
ID: uuid.NewString(),
|
||||||
|
Name: name,
|
||||||
|
SizeBytes: size,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := d.db.ExecContext(ctx,
|
||||||
|
`INSERT INTO widgets (id, name, size_bytes, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
|
w.ID, w.Name, w.SizeBytes, w.CreatedAt.Format(timeFormat),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inserting widget: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return w, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWidgets returns the most recently created widgets, newest first,
|
||||||
|
// up to limit.
|
||||||
|
func (d *Database) ListWidgets(ctx context.Context, limit int) ([]Widget, error) {
|
||||||
|
rows, err := d.db.QueryContext(ctx,
|
||||||
|
`SELECT id, name, size_bytes, created_at
|
||||||
|
FROM widgets
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listing widgets: %w", err)
|
||||||
|
}
|
||||||
|
defer func() { _ = rows.Close() }()
|
||||||
|
|
||||||
|
widgets := []Widget{}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var (
|
||||||
|
w Widget
|
||||||
|
createdAt string
|
||||||
|
)
|
||||||
|
|
||||||
|
scanErr := rows.Scan(&w.ID, &w.Name, &w.SizeBytes, &createdAt)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("scanning widget: %w", scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
w.CreatedAt, scanErr = time.Parse(timeFormat, createdAt)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, fmt.Errorf("parsing widget created_at %q: %w", createdAt, scanErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
widgets = append(widgets, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = rows.Err()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("iterating widgets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return widgets, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountWidgets returns the number of widgets stored.
|
||||||
|
func (d *Database) CountWidgets(ctx context.Context) (int, error) {
|
||||||
|
var n int
|
||||||
|
|
||||||
|
err := d.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM widgets`).Scan(&n)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("counting widgets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 000.sql: the bootstrap migration. It creates only the ledger that
|
||||||
|
-- records which migrations have run; every other migration is recorded
|
||||||
|
-- in it. Applied when the schema_migrations table is missing, and never
|
||||||
|
-- again.
|
||||||
|
--
|
||||||
|
-- Never edit an applied migration. Add a new numbered file instead: the
|
||||||
|
-- ledger records versions, not contents, so an edited file is applied
|
||||||
|
-- nowhere and diverges everywhere.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version INTEGER PRIMARY KEY,
|
||||||
|
applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- 001_widgets.sql: the example table. Delete it when seeding a real
|
||||||
|
-- project and start your own schema at 001 — nothing has been deployed
|
||||||
|
-- yet, so there is no ledger anywhere that would disagree.
|
||||||
|
--
|
||||||
|
-- It is here so that the template's migration runner, model layer and
|
||||||
|
-- tests all exercise a real table rather than an empty database.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS widgets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS widgets_created_at ON widgets (created_at);
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Package globals provides build-time variables injected via ldflags.
|
||||||
|
package globals
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Build-time variables populated from main() and copied into the
|
||||||
|
// Globals object. main() sets them from its own ldflags-injected
|
||||||
|
// values; nothing else writes them.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // Build-time variables set by main().
|
||||||
|
var (
|
||||||
|
Appname string
|
||||||
|
Version string
|
||||||
|
Buildarch string
|
||||||
|
)
|
||||||
|
|
||||||
|
// Globals holds build-time metadata about the application.
|
||||||
|
type Globals struct {
|
||||||
|
Appname string
|
||||||
|
Version string
|
||||||
|
Buildarch string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a Globals instance from the package-level build-time
|
||||||
|
// variables.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
|
func New(lc fx.Lifecycle) (*Globals, error) {
|
||||||
|
arch := Buildarch
|
||||||
|
if arch == "" {
|
||||||
|
arch = runtime.GOARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Globals{
|
||||||
|
Appname: Appname,
|
||||||
|
Buildarch: arch,
|
||||||
|
Version: Version,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// errBadWidgetName is a rejected form value, not a fault. It exists
|
||||||
|
// so that h.fail always has a non-nil error to log: a rejection
|
||||||
|
// with no error recorded is a rejection nobody can explain later.
|
||||||
|
errBadWidgetName = errors.New("widget name is empty or too long")
|
||||||
|
|
||||||
|
// errBadWidgetSize is a size field that is not a byte count.
|
||||||
|
errBadWidgetSize = errors.New("widget size is not a byte count")
|
||||||
|
)
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Package handlers holds the HTTP handlers. They are methods on one
|
||||||
|
// struct whose dependencies come from fx, so a handler never reaches
|
||||||
|
// for a package-level singleton and a test can build the struct with
|
||||||
|
// exactly the collaborators it wants.
|
||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/database"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/middleware"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/render"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines dependencies for Handlers.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Config *config.Config
|
||||||
|
Globals *globals.Globals
|
||||||
|
Logger *logger.Logger
|
||||||
|
Database *database.Database
|
||||||
|
Renderer *render.Renderer
|
||||||
|
Sentry *telemetry.Sentry
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handlers is the set of HTTP handlers.
|
||||||
|
type Handlers struct {
|
||||||
|
params Params
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates the handler set.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
|
return &Handlers{params: params, log: params.Logger.Get()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotFound answers unmatched routes with the error page rather than
|
||||||
|
// net/http's bare text, so a 404 still carries the site's own headers
|
||||||
|
// and chrome.
|
||||||
|
func (h *Handlers) NotFound() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := render.ErrorPage{
|
||||||
|
Page: h.page(r),
|
||||||
|
Status: http.StatusNotFound,
|
||||||
|
Message: "No such page.",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.params.Renderer.HTML(w, http.StatusNotFound, "error.html", data)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("rendering 404 failed", "error", err)
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MethodNotAllowed answers a known path with the wrong method.
|
||||||
|
func (h *Handlers) MethodNotAllowed() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
data := render.ErrorPage{
|
||||||
|
Page: h.page(r),
|
||||||
|
Status: http.StatusMethodNotAllowed,
|
||||||
|
Message: "That method is not allowed here.",
|
||||||
|
}
|
||||||
|
|
||||||
|
err := h.params.Renderer.HTML(w, http.StatusMethodNotAllowed, "error.html", data)
|
||||||
|
if err != nil {
|
||||||
|
h.log.Error("rendering 405 failed", "error", err)
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// page builds the common template data for r.
|
||||||
|
func (h *Handlers) page(r *http.Request) render.Page {
|
||||||
|
return h.params.Renderer.NewPage(middleware.CSRFField(r))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail reports a handler error and answers with the error page.
|
||||||
|
//
|
||||||
|
// The message shown to the client is chosen by the caller and is never
|
||||||
|
// the error's text: an error from the database layer carries a query,
|
||||||
|
// possibly a value out of a row, and always more about the internals
|
||||||
|
// than a stranger should be given. The error itself goes to the log and
|
||||||
|
// to Sentry, tied to the request id that is also in the response
|
||||||
|
// header, so the two can be joined afterwards.
|
||||||
|
func (h *Handlers) fail(
|
||||||
|
w http.ResponseWriter, r *http.Request, status int, message string, err error,
|
||||||
|
) {
|
||||||
|
h.log.Error("handler error",
|
||||||
|
"id", middleware.RequestIDFrom(r.Context()),
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"status", status,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status >= http.StatusInternalServerError {
|
||||||
|
h.params.Sentry.CaptureError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := render.ErrorPage{
|
||||||
|
Page: h.page(r),
|
||||||
|
Status: status,
|
||||||
|
Message: message,
|
||||||
|
}
|
||||||
|
|
||||||
|
renderErr := h.params.Renderer.HTML(w, status, "error.html", data)
|
||||||
|
if renderErr != nil {
|
||||||
|
// The error page itself failed. Anything further would be
|
||||||
|
// another chance to fail, so this is the floor: a plain
|
||||||
|
// status, and the reason in the log.
|
||||||
|
h.log.Error("rendering error page failed", "error", renderErr)
|
||||||
|
http.Error(w, http.StatusText(status), status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthResponse is the healthcheck body. It is a typed struct rather
|
||||||
|
// than a map so that the shape is part of the code and a change to it
|
||||||
|
// shows up in a diff: something is always parsing this.
|
||||||
|
type HealthResponse struct {
|
||||||
|
OK bool `json:"ok"`
|
||||||
|
App string `json:"app"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
DatabaseOK bool `json:"database_ok"`
|
||||||
|
SentryEnabled bool `json:"sentry_enabled"`
|
||||||
|
MetricsProtected bool `json:"metrics_protected"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Healthcheck answers with the process's own view of whether it is
|
||||||
|
// working. It touches the database on purpose: a health endpoint that
|
||||||
|
// only proves the HTTP server is up will report healthy through the
|
||||||
|
// entire outage that matters.
|
||||||
|
//
|
||||||
|
// A failure answers 503, not 200-with-ok-false. Everything that reads
|
||||||
|
// this — a load balancer, a container runtime, a monitoring probe —
|
||||||
|
// looks at the status code first, and several look at nothing else.
|
||||||
|
func (h *Handlers) Healthcheck() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resp := HealthResponse{
|
||||||
|
App: h.params.Globals.Appname,
|
||||||
|
Version: h.params.Globals.Version,
|
||||||
|
SentryEnabled: h.params.Sentry.Enabled(),
|
||||||
|
MetricsProtected: h.params.Config.MetricsUser != "",
|
||||||
|
}
|
||||||
|
|
||||||
|
versions, err := h.params.Database.AppliedVersions(r.Context())
|
||||||
|
if err == nil {
|
||||||
|
resp.DatabaseOK = true
|
||||||
|
resp.OK = true
|
||||||
|
|
||||||
|
if len(versions) > 0 {
|
||||||
|
resp.SchemaVersion = slices.Max(versions)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
h.log.Error("healthcheck: database unreachable", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
status := http.StatusOK
|
||||||
|
if !resp.OK {
|
||||||
|
status = http.StatusServiceUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
// A cached healthcheck is a healthcheck that reports the past.
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
encodeErr := json.NewEncoder(w).Encode(resp)
|
||||||
|
if encodeErr != nil {
|
||||||
|
// The status and headers are already sent, so there is
|
||||||
|
// nothing to answer with; the log is the only record left.
|
||||||
|
h.log.Error("healthcheck: encoding response failed", "error", encodeErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Panic is a route that panics, mounted only when DEBUG is on. It is
|
||||||
|
// how the panic recoverer is exercised by hand in a running process;
|
||||||
|
// the automated proof is in the middleware tests.
|
||||||
|
func (h *Handlers) Panic() http.HandlerFunc {
|
||||||
|
return func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
panic("deliberate panic from the debug route")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
// widgetListLimit bounds the index query. An unbounded SELECT is fine
|
||||||
|
// on the day it is written and is the outage two years later.
|
||||||
|
const widgetListLimit = 50
|
||||||
|
|
||||||
|
// maxWidgetNameLen matches the maxlength on the form input. The form is
|
||||||
|
// a courtesy; this is the rule.
|
||||||
|
const maxWidgetNameLen = 200
|
||||||
|
|
||||||
|
// Index renders the front page from the embedded template, listing the
|
||||||
|
// most recent widgets.
|
||||||
|
func (h *Handlers) Index() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
|
||||||
|
widgets, err := h.params.Database.ListWidgets(ctx, widgetListLimit)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, http.StatusInternalServerError, "Could not load widgets.", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := h.params.Database.CountWidgets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, http.StatusInternalServerError, "Could not count widgets.", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
data := render.IndexPage{
|
||||||
|
Page: h.page(r),
|
||||||
|
WidgetCount: count,
|
||||||
|
Widgets: widgets,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = h.params.Renderer.HTML(w, http.StatusOK, "index.html", data)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, http.StatusInternalServerError, "Could not render the page.", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateWidget handles the form POST. State-changing, so it is behind
|
||||||
|
// CSRF; see internal/server/routes.go for where that is applied.
|
||||||
|
func (h *Handlers) CreateWidget() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// ParseForm reads the body, which BodyLimit has already capped:
|
||||||
|
// an oversized submission fails here rather than being buffered
|
||||||
|
// in full first.
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, http.StatusBadRequest, "Could not read the form.", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
name := strings.TrimSpace(r.PostFormValue("name"))
|
||||||
|
if name == "" || len(name) > maxWidgetNameLen {
|
||||||
|
h.fail(w, r, http.StatusBadRequest,
|
||||||
|
"A widget needs a name of 1 to 200 characters.", errBadWidgetName)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := parseSize(r.PostFormValue("size"))
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, http.StatusBadRequest,
|
||||||
|
"Size must be a byte count, like 4096 or 4KiB.", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = h.params.Database.CreateWidget(r.Context(), name, size)
|
||||||
|
if err != nil {
|
||||||
|
h.fail(w, r, http.StatusInternalServerError, "Could not save the widget.", err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST/redirect/GET: a reload must not repeat the write.
|
||||||
|
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSize accepts an empty value as zero and anything else as a
|
||||||
|
// human-readable byte size.
|
||||||
|
func parseSize(s string) (int64, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if s == "" {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := humanize.ParseBytes(s)
|
||||||
|
if err != nil {
|
||||||
|
return 0, errBadWidgetSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// A size beyond this is not a widget, it is a typo with a suffix.
|
||||||
|
const maxWidgetSize = uint64(1) << 50
|
||||||
|
if n > maxWidgetSize {
|
||||||
|
return 0, errBadWidgetSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return int64(n), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Package logger provides structured logging using stdlib log/slog.
|
||||||
|
//
|
||||||
|
// JSON output always, one format for every environment, TTY or not. A
|
||||||
|
// log line is a record to be queried, not prose to be read.
|
||||||
|
package logger
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines dependencies for Logger.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Globals *globals.Globals
|
||||||
|
|
||||||
|
// Output is the log destination. Optional in the fx graph: when
|
||||||
|
// absent (production) it defaults to os.Stdout; tests inject a
|
||||||
|
// buffer here to assert on the output contract.
|
||||||
|
Output io.Writer `optional:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger wraps slog with application-specific functionality.
|
||||||
|
type Logger struct {
|
||||||
|
log *slog.Logger
|
||||||
|
level *slog.LevelVar
|
||||||
|
globals *globals.Globals
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a new Logger instance.
|
||||||
|
func New(_ fx.Lifecycle, params Params) (*Logger, error) {
|
||||||
|
l := &Logger{
|
||||||
|
level: new(slog.LevelVar),
|
||||||
|
globals: params.Globals,
|
||||||
|
}
|
||||||
|
l.level.Set(slog.LevelInfo)
|
||||||
|
|
||||||
|
out := params.Output
|
||||||
|
if out == nil {
|
||||||
|
out = os.Stdout
|
||||||
|
}
|
||||||
|
|
||||||
|
// replaceAttr simplifies the source attribute to "file.go:line".
|
||||||
|
replaceAttr := func(_ []string, a slog.Attr) slog.Attr {
|
||||||
|
if a.Key == slog.SourceKey {
|
||||||
|
if src, ok := a.Value.Any().(*slog.Source); ok {
|
||||||
|
a.Value = slog.StringValue(
|
||||||
|
fmt.Sprintf("%s:%d", filepath.Base(src.File), src.Line),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewJSONHandler(out, &slog.HandlerOptions{
|
||||||
|
Level: l.level,
|
||||||
|
AddSource: true,
|
||||||
|
ReplaceAttr: replaceAttr,
|
||||||
|
})
|
||||||
|
|
||||||
|
l.log = slog.New(handler)
|
||||||
|
|
||||||
|
return l, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnableDebugLogging sets the log level to debug.
|
||||||
|
func (l *Logger) EnableDebugLogging() {
|
||||||
|
l.level.Set(slog.LevelDebug)
|
||||||
|
l.log.Debug("debug logging enabled", "debug", true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the underlying slog.Logger.
|
||||||
|
func (l *Logger) Get() *slog.Logger {
|
||||||
|
return l.log
|
||||||
|
}
|
||||||
|
|
||||||
|
// Identify logs application startup information.
|
||||||
|
func (l *Logger) Identify() {
|
||||||
|
l.log.Info("starting",
|
||||||
|
"appname", l.globals.Appname,
|
||||||
|
"version", l.globals.Version,
|
||||||
|
"arch", l.globals.Buildarch,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BodyLimit caps how much of a request body a handler can read.
|
||||||
|
//
|
||||||
|
// http.MaxBytesReader is the mechanism, and the reason to use it rather
|
||||||
|
// than checking Content-Length is that Content-Length is a claim: a
|
||||||
|
// chunked request does not send one, and a lying one is trivial to
|
||||||
|
// send. MaxBytesReader counts the bytes that actually arrive and makes
|
||||||
|
// the read fail past the cap, so the ceiling holds whatever the headers
|
||||||
|
// said.
|
||||||
|
//
|
||||||
|
// It also sets the response's error status itself (413) when the limit
|
||||||
|
// is hit during a read, so a handler that ignores the read error still
|
||||||
|
// cannot serve a success off a truncated body.
|
||||||
|
//
|
||||||
|
// Content-Length is still checked first, as an early refusal: it costs
|
||||||
|
// nothing and it lets an oversized upload be rejected before it is
|
||||||
|
// transferred.
|
||||||
|
func (m *Middleware) BodyLimit() func(http.Handler) http.Handler {
|
||||||
|
limit := m.cfg.MaxRequestBody
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.ContentLength > limit {
|
||||||
|
w.Header().Set("Content-Length", strconv.Itoa(len(tooLargeBody)))
|
||||||
|
http.Error(w, tooLargeBody, http.StatusRequestEntityTooLarge)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.Body = http.MaxBytesReader(w, r.Body, limit)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tooLargeBody is the response to an oversized request. It names no
|
||||||
|
// limit: the number is an operational detail and telling a caller
|
||||||
|
// exactly where the ceiling is only helps them sit under it.
|
||||||
|
const tooLargeBody = "request body too large"
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
)
|
||||||
|
|
||||||
|
// csrfCookieName is deliberately not the library default: a name that
|
||||||
|
// says which service issued it makes a cookie jar readable, and two
|
||||||
|
// services on sibling hosts do not fight over one name.
|
||||||
|
const csrfCookieName = "simplexcalc_csrf"
|
||||||
|
|
||||||
|
// csrfMaxAge bounds how long a token stays valid, in seconds.
|
||||||
|
const csrfMaxAge = 12 * 60 * 60
|
||||||
|
|
||||||
|
// csrfKeyBytes is the key length gorilla/csrf requires.
|
||||||
|
const csrfKeyBytes = 32
|
||||||
|
|
||||||
|
// CSRF protects state-changing routes (POST, PUT, PATCH, DELETE). Safe
|
||||||
|
// methods pass through and are issued a token.
|
||||||
|
//
|
||||||
|
// The key comes from config: CSRF_KEY when set, otherwise a random key
|
||||||
|
// generated here and logged as such. An ephemeral key is correct for
|
||||||
|
// development and wrong for anything with more than one replica or more
|
||||||
|
// than one process lifetime, because a token issued by one key is
|
||||||
|
// rejected by another — the user sees a failed form submission, not a
|
||||||
|
// security event. That is why it is a warning at startup and a
|
||||||
|
// documented configuration key rather than a silent default.
|
||||||
|
func (m *Middleware) CSRF() func(http.Handler) http.Handler {
|
||||||
|
key := m.cfg.CSRFKey
|
||||||
|
|
||||||
|
if m.cfg.CSRFKeyEphemeral {
|
||||||
|
key = make([]byte, csrfKeyBytes)
|
||||||
|
|
||||||
|
// crypto/rand.Read cannot fail on any supported platform; it
|
||||||
|
// panics internally rather than returning an error a caller
|
||||||
|
// might ignore. A key that is not random is not a key, so
|
||||||
|
// there is nothing to fall back to here anyway.
|
||||||
|
_, _ = rand.Read(key)
|
||||||
|
|
||||||
|
m.log.Warn("CSRF_KEY is not set; using a random key for this process",
|
||||||
|
"consequence", "tokens do not survive a restart and are not shared between replicas")
|
||||||
|
}
|
||||||
|
|
||||||
|
protect := csrf.Protect(
|
||||||
|
key,
|
||||||
|
// Secure cookies require TLS, which is absent in local
|
||||||
|
// development; tying the flag to the same switch that governs
|
||||||
|
// HSTS keeps "is this a production deployment" a single
|
||||||
|
// decision rather than two that can disagree.
|
||||||
|
csrf.Secure(m.cfg.HSTS),
|
||||||
|
csrf.HttpOnly(true),
|
||||||
|
csrf.SameSite(csrf.SameSiteLaxMode),
|
||||||
|
csrf.Path("/"),
|
||||||
|
csrf.CookieName(csrfCookieName),
|
||||||
|
csrf.MaxAge(csrfMaxAge),
|
||||||
|
csrf.ErrorHandler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
m.log.Warn("csrf rejection",
|
||||||
|
"id", RequestIDFrom(r.Context()),
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"reason", csrf.FailureReason(r).Error(),
|
||||||
|
)
|
||||||
|
|
||||||
|
http.Error(w, "invalid CSRF token", http.StatusForbidden)
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
// markScheme must be OUTSIDE protect: it sets a context value that
|
||||||
|
// protect reads, so it has to run first.
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return markScheme(protect(next))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// markScheme tells gorilla/csrf whether the browser's connection was
|
||||||
|
// plaintext, because the library cannot tell and assumes it was not.
|
||||||
|
//
|
||||||
|
// Its strict Referer check is for TLS only, and it treats every request
|
||||||
|
// as TLS unless a context value says otherwise. A service behind a
|
||||||
|
// TLS-terminating reverse proxy receives plaintext HTTP with an
|
||||||
|
// https:// Referer — the library then applies the TLS rules to a
|
||||||
|
// plaintext connection and rejects every form submission, which is a
|
||||||
|
// total outage of every state-changing route rather than a subtle bug.
|
||||||
|
// Left alone, the same misreading rejects plain HTTP in development for
|
||||||
|
// the mirror-image reason.
|
||||||
|
//
|
||||||
|
// The rule: HTTPS if the connection is TLS, or if a proxy said so with
|
||||||
|
// X-Forwarded-Proto. Trusting that header is safe in this one
|
||||||
|
// direction — the only thing an attacker gains by setting it is
|
||||||
|
// STRICTER checking of their own request. The reverse (inferring
|
||||||
|
// plaintext) is what would weaken the check, and nothing a client sends
|
||||||
|
// can cause it.
|
||||||
|
//
|
||||||
|
// A deployment behind a proxy that does not set X-Forwarded-Proto gets
|
||||||
|
// the plaintext ruleset: tokens still work, and the extra Referer check
|
||||||
|
// TLS would have added is not applied. Configure the proxy.
|
||||||
|
func markScheme(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.TLS == nil && !strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||||
|
r = csrf.PlaintextHTTPRequest(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// CSRFField returns the hidden input for r's token, for a template to
|
||||||
|
// place inside a form. Handlers call this rather than importing
|
||||||
|
// gorilla/csrf, so the library stays swappable behind this package.
|
||||||
|
func CSRFField(r *http.Request) template.HTML {
|
||||||
|
return csrf.TemplateField(r)
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
// Package middleware holds the HTTP middleware chain: request
|
||||||
|
// identity, logging, metrics, panic recovery, timeouts, body caps,
|
||||||
|
// security headers and CSRF.
|
||||||
|
//
|
||||||
|
// Order matters and is fixed in internal/server/routes.go, not here.
|
||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// contextKey is this package's private context key type, so no other
|
||||||
|
// package can collide with or read these values by accident.
|
||||||
|
type contextKey string
|
||||||
|
|
||||||
|
// requestIDKey carries the per-request id.
|
||||||
|
const requestIDKey contextKey = "request-id"
|
||||||
|
|
||||||
|
// RequestIDHeader is the response header the id is echoed in, so a
|
||||||
|
// user reporting a failure can quote something that finds the log line.
|
||||||
|
const RequestIDHeader = "X-Request-Id"
|
||||||
|
|
||||||
|
// Params defines dependencies for Middleware.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Config *config.Config
|
||||||
|
Logger *logger.Logger
|
||||||
|
Sentry *telemetry.Sentry
|
||||||
|
Metrics *telemetry.Metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Middleware is the set of handlers, built once and reused.
|
||||||
|
type Middleware struct {
|
||||||
|
params Params
|
||||||
|
log *slog.Logger
|
||||||
|
cfg *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates the middleware set.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
|
func New(lc fx.Lifecycle, params Params) (*Middleware, error) {
|
||||||
|
return &Middleware{
|
||||||
|
params: params,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
cfg: params.Config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestIDFrom returns the id assigned to r's context, or "" outside a
|
||||||
|
// request that went through RequestID.
|
||||||
|
func RequestIDFrom(ctx context.Context) string {
|
||||||
|
id, _ := ctx.Value(requestIDKey).(string)
|
||||||
|
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestID assigns each request an id and echoes it. An id supplied by
|
||||||
|
// the client is ignored: it is attacker-controlled, it would let a
|
||||||
|
// caller collide two unrelated requests in the log, and there is no
|
||||||
|
// trusted proxy contract here that would make it meaningful.
|
||||||
|
func (m *Middleware) RequestID() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := uuid.NewString()
|
||||||
|
|
||||||
|
w.Header().Set(RequestIDHeader, id)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(
|
||||||
|
context.WithValue(r.Context(), requestIDKey, id),
|
||||||
|
))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestLogger logs one line per completed request.
|
||||||
|
func (m *Middleware) RequestLogger() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
rec := newResponseRecorder(w)
|
||||||
|
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
|
||||||
|
m.log.Info("request",
|
||||||
|
"id", RequestIDFrom(r.Context()),
|
||||||
|
"method", r.Method,
|
||||||
|
"path", r.URL.Path,
|
||||||
|
"route", routePattern(r),
|
||||||
|
"status", rec.Status(),
|
||||||
|
"bytes", rec.written,
|
||||||
|
"duration_ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics records the Prometheus series for each request.
|
||||||
|
func (m *Middleware) Metrics() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
start := time.Now()
|
||||||
|
rec := newResponseRecorder(w)
|
||||||
|
|
||||||
|
m.params.Metrics.InFlightAdd(1)
|
||||||
|
defer m.params.Metrics.InFlightAdd(-1)
|
||||||
|
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
|
||||||
|
m.params.Metrics.Observe(
|
||||||
|
r.Method,
|
||||||
|
routePattern(r),
|
||||||
|
strconv.Itoa(rec.Status()),
|
||||||
|
time.Since(start),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout bounds handler execution with the configured request timeout.
|
||||||
|
// The handler sees a context with a deadline; a handler that ignores it
|
||||||
|
// still runs to completion, so handlers must pass the context down to
|
||||||
|
// everything that can block.
|
||||||
|
func (m *Middleware) Timeout() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), m.cfg.RequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// routePattern returns the chi route pattern for r, or "unmatched" when
|
||||||
|
// no route matched (a 404). It is what the metrics and the log are
|
||||||
|
// labelled by; see the comment on the requests counter for why the path
|
||||||
|
// is not.
|
||||||
|
func routePattern(r *http.Request) string {
|
||||||
|
rctx := chi.RouteContext(r.Context())
|
||||||
|
if rctx == nil {
|
||||||
|
return "unmatched"
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := rctx.RoutePattern()
|
||||||
|
if pattern == "" {
|
||||||
|
return "unmatched"
|
||||||
|
}
|
||||||
|
|
||||||
|
return pattern
|
||||||
|
}
|
||||||
@@ -0,0 +1,314 @@
|
|||||||
|
package middleware_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/middleware"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMiddleware builds the set against a given config, with logging
|
||||||
|
// discarded and telemetry disabled.
|
||||||
|
func newMiddleware(t *testing.T, cfg *config.Config) *middleware.Middleware {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
g := &globals.Globals{Appname: "simplexcalc", Version: "test"}
|
||||||
|
|
||||||
|
log, err := logger.New(nil, logger.Params{Globals: g, Output: io.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sentry, err := telemetry.NewSentry(nil, telemetry.SentryParams{
|
||||||
|
Config: cfg, Globals: g, Logger: log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building sentry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics, err := telemetry.NewMetrics(telemetry.MetricsParams{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mw, err := middleware.New(nil, middleware.Params{
|
||||||
|
Config: cfg, Logger: log, Sentry: sentry, Metrics: metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building middleware: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mw
|
||||||
|
}
|
||||||
|
|
||||||
|
// getReq and postReq build requests carrying the test's context, so a
|
||||||
|
// handler that respects cancellation is exercised the way the server
|
||||||
|
// exercises it.
|
||||||
|
func getReq(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postReq(t *testing.T, body string) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return httptest.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, "/", strings.NewReader(body),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testConfig() *config.Config {
|
||||||
|
return &config.Config{
|
||||||
|
Port: 8080,
|
||||||
|
HSTS: true,
|
||||||
|
MaxRequestBody: 1024,
|
||||||
|
RequestTimeout: time.Second,
|
||||||
|
ShutdownGrace: time.Second,
|
||||||
|
CSRFKeyEphemeral: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecovererAnswers500 is the point of the panic middleware: net/http
|
||||||
|
// on its own drops the connection, which tells the client nothing about
|
||||||
|
// whose fault it was.
|
||||||
|
func TestRecovererAnswers500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
h := mw.Recoverer()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
panic("boom")
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, getReq(t))
|
||||||
|
|
||||||
|
if w.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want 500", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.Body.Len() == 0 {
|
||||||
|
t.Error("a 500 with no body tells the client nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The panic value must not reach the client.
|
||||||
|
if strings.Contains(w.Body.String(), "boom") {
|
||||||
|
t.Error("the panic value was leaked in the response body")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecovererPassesThroughSuccess: the recovery wrapper must be
|
||||||
|
// invisible when nothing goes wrong, including for the response body.
|
||||||
|
func TestRecovererPassesThroughSuccess(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
h := mw.Recoverer()(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTeapot)
|
||||||
|
_, _ = w.Write([]byte("fine"))
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, getReq(t))
|
||||||
|
|
||||||
|
if w.Code != http.StatusTeapot || w.Body.String() != "fine" {
|
||||||
|
t.Errorf("status = %d body = %q", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSecurityHeadersOnEveryResponse, including responses the handler
|
||||||
|
// never got to write.
|
||||||
|
func TestSecurityHeadersOnEveryResponse(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
notFound := func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := mw.SecurityHeaders()(http.HandlerFunc(notFound))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, getReq(t))
|
||||||
|
|
||||||
|
want := map[string]string{
|
||||||
|
"X-Frame-Options": "DENY",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
"Strict-Transport-Security": "max-age=31536000; includeSubDomains",
|
||||||
|
}
|
||||||
|
|
||||||
|
for header, value := range want {
|
||||||
|
if got := w.Header().Get(header); got != value {
|
||||||
|
t.Errorf("%s = %q, want %q", header, got, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
csp := w.Header().Get("Content-Security-Policy")
|
||||||
|
if !strings.Contains(csp, "default-src 'self'") {
|
||||||
|
t.Errorf("CSP = %q", csp)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(csp, "unsafe-inline") {
|
||||||
|
t.Error("the CSP permits inline script or style")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHSTSOffWhenDisabled: the header must be absent, not empty, so a
|
||||||
|
// developer's browser is never pinned to HTTPS on localhost.
|
||||||
|
func TestHSTSOffWhenDisabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
cfg := testConfig()
|
||||||
|
cfg.HSTS = false
|
||||||
|
|
||||||
|
mw := newMiddleware(t, cfg)
|
||||||
|
|
||||||
|
noop := func(_ http.ResponseWriter, _ *http.Request) {}
|
||||||
|
|
||||||
|
h := mw.SecurityHeaders()(http.HandlerFunc(noop))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, getReq(t))
|
||||||
|
|
||||||
|
if _, ok := w.Header()["Strict-Transport-Security"]; ok {
|
||||||
|
t.Error("HSTS was sent with HSTS disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBodyLimitRefusesDeclaredOversize: a Content-Length over the cap is
|
||||||
|
// refused before the body transfers.
|
||||||
|
func TestBodyLimitRefusesDeclaredOversize(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
reached := false
|
||||||
|
h := mw.BodyLimit()(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
||||||
|
reached = true
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := postReq(t, strings.Repeat("x", 2048))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status = %d, want 413", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if reached {
|
||||||
|
t.Error("the handler ran for an oversized request")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBodyLimitCapsUndeclaredBody is the case Content-Length cannot
|
||||||
|
// catch: a body that arrives without one, or with a lying one, must
|
||||||
|
// still fail at the cap rather than being read in full.
|
||||||
|
func TestBodyLimitCapsUndeclaredBody(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
var readErr error
|
||||||
|
|
||||||
|
h := mw.BodyLimit()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
_, readErr = io.ReadAll(r.Body)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := postReq(t, strings.Repeat("x", 4096))
|
||||||
|
// Undeclared length: what a chunked upload looks like here.
|
||||||
|
req.ContentLength = -1
|
||||||
|
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if readErr == nil {
|
||||||
|
t.Error("reading past the cap succeeded; the limit is not enforced on the read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBodyLimitAllowsNormalRequests, so the cap is not just "refuse
|
||||||
|
// everything".
|
||||||
|
func TestBodyLimitAllowsNormalRequests(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
got := ""
|
||||||
|
h := mw.BodyLimit()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
b, _ := io.ReadAll(r.Body)
|
||||||
|
got = string(b)
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := postReq(t, "small")
|
||||||
|
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
if got != "small" {
|
||||||
|
t.Errorf("body = %q, want %q", got, "small")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequestIDIsAssignedAndNotBorrowed: the id must be this process's,
|
||||||
|
// so a client cannot collide two unrelated requests in the log.
|
||||||
|
func TestRequestIDIsAssignedAndNotBorrowed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
var inHandler string
|
||||||
|
|
||||||
|
h := mw.RequestID()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
inHandler = middleware.RequestIDFrom(r.Context())
|
||||||
|
}))
|
||||||
|
|
||||||
|
req := getReq(t)
|
||||||
|
|
||||||
|
req.Header.Set(middleware.RequestIDHeader, "client-supplied")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if inHandler == "" {
|
||||||
|
t.Fatal("no request id reached the handler")
|
||||||
|
}
|
||||||
|
|
||||||
|
if inHandler == "client-supplied" {
|
||||||
|
t.Error("the client's request id was trusted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.Header().Get(middleware.RequestIDHeader) != inHandler {
|
||||||
|
t.Error("the response header does not carry the id the handler saw")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTimeoutGivesHandlerADeadline. The handler is what has to respect
|
||||||
|
// it, so what is asserted here is that the deadline is there at all.
|
||||||
|
func TestTimeoutGivesHandlerADeadline(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mw := newMiddleware(t, testConfig())
|
||||||
|
|
||||||
|
var hasDeadline bool
|
||||||
|
|
||||||
|
h := mw.Timeout()(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||||
|
_, hasDeadline = r.Context().Deadline()
|
||||||
|
}))
|
||||||
|
|
||||||
|
h.ServeHTTP(httptest.NewRecorder(), getReq(t))
|
||||||
|
|
||||||
|
if !hasDeadline {
|
||||||
|
t.Error("the handler's context carries no deadline")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// panicBody is the entire response a recovered panic produces. No
|
||||||
|
// template, no detail: the client learns that the request failed, and
|
||||||
|
// everything about why goes to the log and to Sentry, where it is not
|
||||||
|
// attacker-readable.
|
||||||
|
const panicBody = "internal server error"
|
||||||
|
|
||||||
|
// Recoverer turns a panicking handler into a 500 rather than a dropped
|
||||||
|
// connection.
|
||||||
|
//
|
||||||
|
// net/http already recovers panics, but what it does is close the
|
||||||
|
// connection without a response, so the client sees a transport error
|
||||||
|
// and no status. Answering 500 is the difference between "the service
|
||||||
|
// is broken" and "the network is broken" for everyone downstream.
|
||||||
|
//
|
||||||
|
// A panic after the response has started cannot be turned into a 500 —
|
||||||
|
// the status is already on the wire — so in that case the connection is
|
||||||
|
// deliberately dropped by re-panicking to net/http, which is the only
|
||||||
|
// honest signal left that the body is truncated. A truncated 200 that
|
||||||
|
// looks complete is worse than a broken connection.
|
||||||
|
func (m *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
rec := newResponseRecorder(w)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
v := recover()
|
||||||
|
if v == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// http.ErrAbortHandler is net/http's documented way for
|
||||||
|
// a handler to abandon a response on purpose. It is not
|
||||||
|
// a bug, so it is not reported; it is re-raised for
|
||||||
|
// net/http to handle as it always does.
|
||||||
|
//nolint:errorlint,err113 // a sentinel value, compared as net/http documents.
|
||||||
|
if v == http.ErrAbortHandler {
|
||||||
|
panic(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.params.Sentry.CapturePanic(v, debug.Stack())
|
||||||
|
|
||||||
|
if rec.Written() {
|
||||||
|
panic(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
http.Error(rec, panicBody, http.StatusInternalServerError)
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(rec, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// responseRecorder remembers the status code and byte count for the
|
||||||
|
// logger and the metrics middleware. net/http gives no way to read
|
||||||
|
// them back off an http.ResponseWriter, so the only way to know what
|
||||||
|
// was answered is to be the thing that answered it.
|
||||||
|
type responseRecorder struct {
|
||||||
|
http.ResponseWriter
|
||||||
|
|
||||||
|
status int
|
||||||
|
written int64
|
||||||
|
wrote bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newResponseRecorder(w http.ResponseWriter) *responseRecorder {
|
||||||
|
// A handler that writes a body without calling WriteHeader has
|
||||||
|
// sent 200; recording that up front means Status() is right for
|
||||||
|
// the common case without waiting for a call that never comes.
|
||||||
|
return &responseRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) WriteHeader(status int) {
|
||||||
|
if r.wrote {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
r.status = status
|
||||||
|
r.wrote = true
|
||||||
|
|
||||||
|
r.ResponseWriter.WriteHeader(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *responseRecorder) Write(b []byte) (int, error) {
|
||||||
|
r.wrote = true
|
||||||
|
|
||||||
|
n, err := r.ResponseWriter.Write(b)
|
||||||
|
r.written += int64(n)
|
||||||
|
|
||||||
|
//nolint:wrapcheck // pass-through writer: wrapping would obscure the underlying error.
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status returns the status code that was sent.
|
||||||
|
func (r *responseRecorder) Status() int {
|
||||||
|
return r.status
|
||||||
|
}
|
||||||
|
|
||||||
|
// Written reports whether anything has been sent yet. The panic
|
||||||
|
// recoverer needs this: it can only substitute a 500 for a response
|
||||||
|
// that has not started.
|
||||||
|
func (r *responseRecorder) Written() bool {
|
||||||
|
return r.wrote
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap lets http.ResponseController reach the underlying writer, so
|
||||||
|
// wrapping does not cost the handler flushing or deadline control.
|
||||||
|
func (r *responseRecorder) Unwrap() http.ResponseWriter {
|
||||||
|
return r.ResponseWriter
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
// Security response headers.
|
||||||
|
const (
|
||||||
|
// hstsValue is served even where TLS terminates at a reverse
|
||||||
|
// proxy, so the browser enforces HTTPS end to end. Off when
|
||||||
|
// config.HSTS is false (development), because pinning a
|
||||||
|
// developer's browser to HTTPS on localhost is a self-inflicted
|
||||||
|
// outage that outlives the process.
|
||||||
|
hstsValue = "max-age=31536000; includeSubDomains"
|
||||||
|
|
||||||
|
// cspValue is the baseline. Every template ships with external CSS
|
||||||
|
// and no inline script, style or event handler, so nothing needs
|
||||||
|
// 'unsafe-inline' and nothing should be given it: the moment a
|
||||||
|
// project seeded from this template adds 'unsafe-inline', the
|
||||||
|
// policy stops being a defence against injected script and becomes
|
||||||
|
// decoration.
|
||||||
|
cspValue = "default-src 'self'; " +
|
||||||
|
"base-uri 'self'; " +
|
||||||
|
"form-action 'self'; " +
|
||||||
|
"frame-ancestors 'none'; " +
|
||||||
|
"object-src 'none'"
|
||||||
|
|
||||||
|
// permissionsPolicyValue denies the browser features this
|
||||||
|
// application does not use.
|
||||||
|
permissionsPolicyValue = "accelerometer=(), autoplay=(), camera=(), " +
|
||||||
|
"display-capture=(), encrypted-media=(), geolocation=(), " +
|
||||||
|
"gyroscope=(), magnetometer=(), microphone=(), midi=(), " +
|
||||||
|
"payment=(), picture-in-picture=(), " +
|
||||||
|
"publickey-credentials-get=(), screen-wake-lock=(), usb=(), " +
|
||||||
|
"xr-spatial-tracking=()"
|
||||||
|
|
||||||
|
referrerPolicyValue = "strict-origin-when-cross-origin"
|
||||||
|
frameOptionsValue = "DENY"
|
||||||
|
contentTypeOptsVal = "nosniff"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SecurityHeaders sets the response security headers before the handler
|
||||||
|
// runs, so they are on every response the router produces — 404s,
|
||||||
|
// handler error bodies, static assets, and the bare 500 the panic
|
||||||
|
// recoverer writes.
|
||||||
|
//
|
||||||
|
// X-Frame-Options duplicates the CSP frame-ancestors directive on
|
||||||
|
// purpose, for browsers that do not implement the latter.
|
||||||
|
func (m *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
|
hsts := m.cfg.HSTS
|
||||||
|
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h := w.Header()
|
||||||
|
|
||||||
|
if hsts {
|
||||||
|
h.Set("Strict-Transport-Security", hstsValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.Set("Content-Security-Policy", cspValue)
|
||||||
|
h.Set("X-Frame-Options", frameOptionsValue)
|
||||||
|
h.Set("X-Content-Type-Options", contentTypeOptsVal)
|
||||||
|
h.Set("Referrer-Policy", referrerPolicyValue)
|
||||||
|
h.Set("Permissions-Policy", permissionsPolicyValue)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package render
|
||||||
|
|
||||||
|
import "errors"
|
||||||
|
|
||||||
|
var (
|
||||||
|
// errNoTemplates means the embed matched nothing — a build that
|
||||||
|
// produced a binary with no pages in it.
|
||||||
|
errNoTemplates = errors.New("no page templates were embedded")
|
||||||
|
|
||||||
|
// errUnknownTemplate means a handler asked for a page that is not
|
||||||
|
// in the embedded set: a typo, caught by that handler's test.
|
||||||
|
errUnknownTemplate = errors.New("unknown template")
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// Package render parses the embedded templates once, at startup, and
|
||||||
|
// executes them into a buffer before writing anything to the client.
|
||||||
|
//
|
||||||
|
// Two decisions worth keeping when this is seeded into a real project:
|
||||||
|
//
|
||||||
|
// - Every template is parsed in New. A template that does not compile
|
||||||
|
// is a process that does not start, rather than a 500 the first time
|
||||||
|
// someone visits the page it broke.
|
||||||
|
// - Execution goes to a buffer first. A template that fails halfway
|
||||||
|
// through would otherwise have already written a 200 and half a
|
||||||
|
// page, and the error could no longer be reported as one.
|
||||||
|
package render
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/database"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
// baseTemplate is the outer document every page is rendered through.
|
||||||
|
const baseTemplate = "base"
|
||||||
|
|
||||||
|
// Params defines dependencies for Renderer.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Globals *globals.Globals
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderer holds one compiled template set per page.
|
||||||
|
type Renderer struct {
|
||||||
|
pages map[string]*template.Template
|
||||||
|
globals *globals.Globals
|
||||||
|
started time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page is the data every template can rely on, embedded by the
|
||||||
|
// page-specific types below so that promoted fields keep the templates
|
||||||
|
// free of a data-envelope prefix.
|
||||||
|
type Page struct {
|
||||||
|
AppName string
|
||||||
|
Version string
|
||||||
|
Buildarch string
|
||||||
|
Uptime string
|
||||||
|
|
||||||
|
// CSRFField is the hidden input gorilla/csrf validates. It is
|
||||||
|
// template.HTML because it is markup this process generated, not
|
||||||
|
// input; nothing user-supplied is ever assigned to it.
|
||||||
|
CSRFField template.HTML
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexPage is the data for index.html.
|
||||||
|
type IndexPage struct {
|
||||||
|
Page
|
||||||
|
|
||||||
|
WidgetCount int
|
||||||
|
Widgets []database.Widget
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrorPage is the data for error.html.
|
||||||
|
type ErrorPage struct {
|
||||||
|
Page
|
||||||
|
|
||||||
|
Status int
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
// funcs are the template helpers. Deliberately few: logic belongs in
|
||||||
|
// the handler, where it can be tested without parsing HTML.
|
||||||
|
func funcs() template.FuncMap {
|
||||||
|
return template.FuncMap{
|
||||||
|
// bytes renders a byte count the way an operator reads one.
|
||||||
|
"bytes": func(n int64) string {
|
||||||
|
if n < 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
return humanize.IBytes(uint64(n))
|
||||||
|
},
|
||||||
|
// since renders a timestamp as "3 minutes ago".
|
||||||
|
"since": humanize.Time,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// New compiles every page template against the base document and the
|
||||||
|
// partials.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
|
func New(lc fx.Lifecycle, params Params) (*Renderer, error) {
|
||||||
|
r := &Renderer{
|
||||||
|
pages: map[string]*template.Template{},
|
||||||
|
globals: params.Globals,
|
||||||
|
started: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
shared, pages, err := split(templates.FS)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, page := range pages {
|
||||||
|
// Each page gets its own set: pages define blocks of the same
|
||||||
|
// names ("title", "content"), so parsing them all into one
|
||||||
|
// template would leave whichever was parsed last defining both
|
||||||
|
// for everybody.
|
||||||
|
set := template.New(baseTemplate).Funcs(funcs())
|
||||||
|
|
||||||
|
set, err = set.ParseFS(templates.FS, append(append([]string{}, shared...), page)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parsing template %s: %w", page, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r.pages[path.Base(page)] = set
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(r.pages) == 0 {
|
||||||
|
return nil, errNoTemplates
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// split separates the embedded set into the files every page needs
|
||||||
|
// (the base document and the partials) and the page templates
|
||||||
|
// themselves.
|
||||||
|
func split(fsys fs.FS) ([]string, []string, error) {
|
||||||
|
partials, err := fs.Glob(fsys, "partials/*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("globbing partials: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
top, err := fs.Glob(fsys, "*.html")
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("globbing templates: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var pages []string
|
||||||
|
|
||||||
|
shared := append([]string{}, partials...)
|
||||||
|
|
||||||
|
for _, f := range top {
|
||||||
|
if strings.TrimSuffix(path.Base(f), ".html") == baseTemplate {
|
||||||
|
shared = append(shared, f)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pages = append(pages, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(shared)
|
||||||
|
sort.Strings(pages)
|
||||||
|
|
||||||
|
return shared, pages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPage returns the common data, filled in from build-time globals
|
||||||
|
// and the request's CSRF field.
|
||||||
|
func (r *Renderer) NewPage(csrfField template.HTML) Page {
|
||||||
|
return Page{
|
||||||
|
AppName: r.globals.Appname,
|
||||||
|
Version: r.globals.Version,
|
||||||
|
Buildarch: r.globals.Buildarch,
|
||||||
|
Uptime: time.Since(r.started).Round(time.Second).String(),
|
||||||
|
CSRFField: csrfField,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute renders a page into w. It buffers first: see the package
|
||||||
|
// comment.
|
||||||
|
func (r *Renderer) Execute(w io.Writer, name string, data any) error {
|
||||||
|
set, ok := r.pages[name]
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("%w: %s", errUnknownTemplate, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := set.ExecuteTemplate(&buf, baseTemplate, data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("executing template %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = buf.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing rendered template %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML renders a page to an http.ResponseWriter with the given status.
|
||||||
|
// A render failure after the buffer succeeded cannot happen, so the
|
||||||
|
// status written here is always the status the client sees.
|
||||||
|
func (r *Renderer) HTML(
|
||||||
|
w http.ResponseWriter, status int, name string, data any,
|
||||||
|
) error {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := r.Execute(&buf, name, data)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = buf.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("writing response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Names returns the compiled page names, sorted. Tests use it to assert
|
||||||
|
// that every embedded page really compiled.
|
||||||
|
func (r *Renderer) Names() []string {
|
||||||
|
names := make([]string, 0, len(r.pages))
|
||||||
|
for name := range r.pages {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(names)
|
||||||
|
|
||||||
|
return names
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package render_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"html/template"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/database"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/render"
|
||||||
|
"sneak.berlin/go/simplexcalc/templates"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newRenderer(t *testing.T) *render.Renderer {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
r, err := render.New(nil, render.Params{
|
||||||
|
Globals: &globals.Globals{
|
||||||
|
Appname: "simplexcalc", Version: "test", Buildarch: "amd64",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("compiling templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEveryEmbeddedPageCompiles is the reason New parses everything at
|
||||||
|
// startup: a template that does not compile must be a process that does
|
||||||
|
// not start, and this is what proves the set is complete rather than
|
||||||
|
// just non-empty.
|
||||||
|
func TestEveryEmbeddedPageCompiles(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRenderer(t)
|
||||||
|
|
||||||
|
embedded, err := templates.FS.ReadDir(".")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("reading embedded templates: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := 0
|
||||||
|
|
||||||
|
for _, e := range embedded {
|
||||||
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".html") && e.Name() != "base.html" {
|
||||||
|
want++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if want == 0 {
|
||||||
|
t.Fatal("no page templates were embedded, so this test proves nothing")
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := len(r.Names()); got != want {
|
||||||
|
t.Errorf("compiled %d pages (%v), embedded %d", got, r.Names(), want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIndexRendersEmbeddedContent renders the page the service serves
|
||||||
|
// at /, with real data, and checks that the base document, both
|
||||||
|
// partials and the page body all made it into one response.
|
||||||
|
func TestIndexRendersEmbeddedContent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRenderer(t)
|
||||||
|
|
||||||
|
data := render.IndexPage{
|
||||||
|
Page: r.NewPage(template.HTML(`<input type="hidden" name="csrf" />`)),
|
||||||
|
WidgetCount: 1,
|
||||||
|
Widgets: []database.Widget{
|
||||||
|
{
|
||||||
|
ID: "abc", Name: "a widget", SizeBytes: 4096,
|
||||||
|
CreatedAt: time.Now().Add(-time.Hour),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := r.Execute(&buf, "index.html", data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rendering index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
"<!doctype html>", // base document
|
||||||
|
`href="/static/css/style.css"`, // base document links the embedded asset
|
||||||
|
"<nav>", // navbar partial
|
||||||
|
"<footer>", // footer partial
|
||||||
|
"a widget", // page data
|
||||||
|
"4.0 KiB", // the bytes template func
|
||||||
|
"ago", // the since template func
|
||||||
|
`name="csrf"`, // the CSRF field was placed
|
||||||
|
} {
|
||||||
|
if !strings.Contains(out, want) {
|
||||||
|
t.Errorf("rendered page is missing %q\n---\n%s", want, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTMLEscapesUserData: the name comes from a form, and html/template
|
||||||
|
// is only a defence if the data actually goes through it.
|
||||||
|
func TestHTMLEscapesUserData(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRenderer(t)
|
||||||
|
|
||||||
|
data := render.IndexPage{
|
||||||
|
Page: r.NewPage(""),
|
||||||
|
WidgetCount: 1,
|
||||||
|
Widgets: []database.Widget{
|
||||||
|
{ID: "x", Name: `<script>alert(1)</script>`, CreatedAt: time.Now()},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := r.Execute(&buf, "index.html", data)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rendering: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(buf.String(), "<script>alert(1)</script>") {
|
||||||
|
t.Error("a widget name was rendered as live markup")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPagesDoNotShareBlocks: index.html and error.html both define
|
||||||
|
// "title" and "content". Parsed into one set, the last one parsed would
|
||||||
|
// define both for everybody, and every page would render as whichever
|
||||||
|
// that was.
|
||||||
|
func TestPagesDoNotShareBlocks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRenderer(t)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
|
||||||
|
err := r.Execute(&buf, "error.html", render.ErrorPage{
|
||||||
|
Page: r.NewPage(""), Status: 404, Message: "gone fishing",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rendering error page: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := buf.String()
|
||||||
|
|
||||||
|
if !strings.Contains(out, "gone fishing") {
|
||||||
|
t.Errorf("error page did not render its own content:\n%s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(out, "widgets (") {
|
||||||
|
t.Error("the error page rendered the index's content block")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownTemplateIsAnError, rather than an empty 200.
|
||||||
|
func TestUnknownTemplateIsAnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRenderer(t)
|
||||||
|
|
||||||
|
err := r.Execute(&bytes.Buffer{}, "nope.html", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("rendering a template that does not exist must fail")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHTMLWritesStatusAndType checks the response contract, since the
|
||||||
|
// handlers rely on it for every page they serve.
|
||||||
|
func TestHTMLWritesStatusAndType(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
r := newRenderer(t)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
err := r.HTML(w, 404, "error.html", render.ErrorPage{
|
||||||
|
Page: r.NewPage(""), Status: 404, Message: "no",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rendering: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if w.Code != 404 {
|
||||||
|
t.Errorf("status = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||||
|
t.Errorf("Content-Type = %q", ct)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTP server hardening limits. These are the connection-level bounds;
|
||||||
|
// the per-request deadline is middleware.Timeout, driven by
|
||||||
|
// REQUEST_TIMEOUT.
|
||||||
|
const (
|
||||||
|
// readHeaderTimeout is the slowloris bound: request headers must
|
||||||
|
// arrive within it, and it starts on connection accept.
|
||||||
|
readHeaderTimeout = 5 * time.Second
|
||||||
|
|
||||||
|
// readTimeout covers headers plus body. Bodies are capped by
|
||||||
|
// MAX_REQUEST_BODY, which transfers well inside this even on a
|
||||||
|
// slow mobile link.
|
||||||
|
readTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// writeTimeout must exceed the per-request timeout: it starts when
|
||||||
|
// the headers are read, so it spans handler execution, and a
|
||||||
|
// smaller value would cut the connection instead of letting the
|
||||||
|
// request context deadline end the request with a status. The
|
||||||
|
// margin is added to whatever REQUEST_TIMEOUT is configured to.
|
||||||
|
writeTimeoutMargin = 15 * time.Second
|
||||||
|
|
||||||
|
// idleTimeout bounds how long an idle keep-alive connection is
|
||||||
|
// held; browsers reconnect transparently.
|
||||||
|
idleTimeout = 120 * time.Second
|
||||||
|
|
||||||
|
maxHeaderBytes = 1 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// newHTTPServer builds the http.Server.
|
||||||
|
//
|
||||||
|
// ErrorLog is set on purpose: net/http internals (TLS handshake
|
||||||
|
// errors, request parse errors, panics net/http itself recovers) write
|
||||||
|
// through it, and unset they would emit plain text on stderr via the
|
||||||
|
// default log package — a few lines of unstructured output in the
|
||||||
|
// middle of a JSON log stream, which is exactly the kind of thing a log
|
||||||
|
// pipeline drops on the floor.
|
||||||
|
func (s *Server) newHTTPServer(listenAddr string) *http.Server {
|
||||||
|
return &http.Server{
|
||||||
|
Addr: listenAddr,
|
||||||
|
ReadHeaderTimeout: readHeaderTimeout,
|
||||||
|
ReadTimeout: readTimeout,
|
||||||
|
WriteTimeout: s.params.Config.RequestTimeout + writeTimeoutMargin,
|
||||||
|
IdleTimeout: idleTimeout,
|
||||||
|
MaxHeaderBytes: maxHeaderBytes,
|
||||||
|
Handler: s,
|
||||||
|
ErrorLog: slog.NewLogLogger(s.log.Handler(), slog.LevelError),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"sneak.berlin/go/simplexcalc/static"
|
||||||
|
)
|
||||||
|
|
||||||
|
// staticPrefix is where the embedded assets are mounted.
|
||||||
|
const staticPrefix = "/static/"
|
||||||
|
|
||||||
|
// staticCacheControl is a year, because the asset set is baked into the
|
||||||
|
// binary: a new build is a new deployment, and a deployment is the only
|
||||||
|
// thing that can change these bytes. Bump the path if that stops being
|
||||||
|
// true.
|
||||||
|
const staticCacheControl = "public, max-age=31536000, immutable"
|
||||||
|
|
||||||
|
// SetupRoutes builds the router. The middleware order is the contract
|
||||||
|
// of this file, and it is this way for reasons:
|
||||||
|
//
|
||||||
|
// 1. RequestID first, so every later line of log and every captured
|
||||||
|
// error can name the request it came from.
|
||||||
|
// 2. Recoverer next-to-outermost, so it covers every handler and every
|
||||||
|
// middleware below it. Above the logger, so a panic still produces
|
||||||
|
// a logged request line.
|
||||||
|
// 3. RequestLogger and Metrics before the work, so both see the final
|
||||||
|
// status of every request including the 500 the recoverer wrote.
|
||||||
|
// 4. SecurityHeaders before anything can write a body — the headers
|
||||||
|
// have to be set before the first Write, and a 404 or a panic
|
||||||
|
// response needs them as much as a page does.
|
||||||
|
// 5. Timeout and BodyLimit before any handler reads a body.
|
||||||
|
// 6. CSRF innermost of the global chain, wrapping only the routes that
|
||||||
|
// can change state.
|
||||||
|
//
|
||||||
|
// /metrics and the healthcheck sit outside CSRF (neither is
|
||||||
|
// state-changing, and a scraper has no token) and outside nothing else.
|
||||||
|
func (s *Server) SetupRoutes() {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
|
||||||
|
r.Use(s.mw.RequestID())
|
||||||
|
r.Use(s.mw.Recoverer())
|
||||||
|
r.Use(s.mw.RequestLogger())
|
||||||
|
r.Use(s.mw.Metrics())
|
||||||
|
r.Use(s.mw.SecurityHeaders())
|
||||||
|
r.Use(s.mw.Timeout())
|
||||||
|
r.Use(s.mw.BodyLimit())
|
||||||
|
|
||||||
|
r.NotFound(s.h.NotFound())
|
||||||
|
r.MethodNotAllowed(s.h.MethodNotAllowed())
|
||||||
|
|
||||||
|
// Operational endpoints: no CSRF, no session, no HTML.
|
||||||
|
r.Get("/.well-known/healthcheck.json", s.h.Healthcheck())
|
||||||
|
r.Method(http.MethodGet, "/metrics", s.metrics.Handler())
|
||||||
|
|
||||||
|
r.Handle(staticPrefix+"*", s.staticHandler())
|
||||||
|
|
||||||
|
// Everything a browser drives, behind CSRF. Safe methods are
|
||||||
|
// unaffected by it except that they are issued a token.
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(s.mw.CSRF())
|
||||||
|
|
||||||
|
r.Get("/", s.h.Index())
|
||||||
|
r.Post("/widgets", s.h.CreateWidget())
|
||||||
|
|
||||||
|
if s.params.Config.Debug {
|
||||||
|
// Only with DEBUG=true: a route that panics on demand is a
|
||||||
|
// denial-of-service primitive in production.
|
||||||
|
r.Get("/debug/panic", s.h.Panic())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
s.router = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// staticHandler serves the embedded assets, without directory listings
|
||||||
|
// and with a long cache lifetime.
|
||||||
|
func (s *Server) staticHandler() http.Handler {
|
||||||
|
fileServer := http.FileServer(filesOnly{inner: http.FS(static.FS)})
|
||||||
|
|
||||||
|
return http.StripPrefix(staticPrefix, http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Cache-Control", staticCacheControl)
|
||||||
|
fileServer.ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// Package server owns the HTTP server lifecycle and the route table.
|
||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/handlers"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/middleware"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Params defines dependencies for Server.
|
||||||
|
type Params struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Logger *logger.Logger
|
||||||
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
|
Middleware *middleware.Middleware
|
||||||
|
Handlers *handlers.Handlers
|
||||||
|
Metrics *telemetry.Metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server is the HTTP server and its lifecycle state.
|
||||||
|
type Server struct {
|
||||||
|
params Params
|
||||||
|
log *slog.Logger
|
||||||
|
mw *middleware.Middleware
|
||||||
|
h *handlers.Handlers
|
||||||
|
metrics *telemetry.Metrics
|
||||||
|
httpServer *http.Server
|
||||||
|
listener net.Listener
|
||||||
|
router *chi.Mux
|
||||||
|
serveErr chan error
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates the Server and hooks it into the fx lifecycle.
|
||||||
|
//
|
||||||
|
// The listener is opened during OnStart, not in a goroutine after it:
|
||||||
|
// binding is the part that fails (port in use, permission denied), and
|
||||||
|
// a failure there has to be a failure to start. A server that logs
|
||||||
|
// "listen failed" from a goroutine while fx reports a successful
|
||||||
|
// startup is a process that is up and serving nothing.
|
||||||
|
func New(lc fx.Lifecycle, params Params) (*Server, error) {
|
||||||
|
s := &Server{
|
||||||
|
params: params,
|
||||||
|
log: params.Logger.Get(),
|
||||||
|
mw: params.Middleware,
|
||||||
|
h: params.Handlers,
|
||||||
|
metrics: params.Metrics,
|
||||||
|
serveErr: make(chan error, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStart: s.start,
|
||||||
|
OnStop: s.stop,
|
||||||
|
})
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the address actually bound, which is what a test that
|
||||||
|
// asked for port 0 needs.
|
||||||
|
func (s *Server) Addr() string {
|
||||||
|
if s.listener == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.listener.Addr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler exposes the router so tests can drive it with
|
||||||
|
// httptest.NewRecorder without binding a port.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return s.router
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP dispatches requests through the router.
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s.router.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// start builds the routes, binds, and begins serving.
|
||||||
|
func (s *Server) start(ctx context.Context) error {
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
addr := ":" + strconv.Itoa(s.params.Config.Port)
|
||||||
|
s.httpServer = s.newHTTPServer(addr)
|
||||||
|
|
||||||
|
// ListenConfig rather than net.Listen: the start context bounds
|
||||||
|
// name resolution and the bind, so a start that cannot bind fails
|
||||||
|
// within fx's start timeout instead of hanging inside it.
|
||||||
|
var lc net.ListenConfig
|
||||||
|
|
||||||
|
ln, err := lc.Listen(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listening on %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.listener = ln
|
||||||
|
|
||||||
|
s.log.Info("http listening",
|
||||||
|
"addr", ln.Addr().String(),
|
||||||
|
"debug", s.params.Config.Debug,
|
||||||
|
"metrics_protected", s.metrics.AuthRequired(),
|
||||||
|
)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
serveErr := s.httpServer.Serve(ln)
|
||||||
|
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||||
|
s.log.Error("http serve failed", "error", serveErr)
|
||||||
|
|
||||||
|
s.serveErr <- serveErr
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.serveErr <- nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop drains in-flight requests, bounded by the stop context.
|
||||||
|
//
|
||||||
|
// The bound is whichever of the two comes first: the deadline fx gives
|
||||||
|
// this hook, or the configured grace period. Taking the minimum is the
|
||||||
|
// point — a shutdown that outlives the context it was given is a
|
||||||
|
// shutdown the supervisor kills with SIGKILL, which is precisely the
|
||||||
|
// abrupt termination the draining was supposed to avoid.
|
||||||
|
//
|
||||||
|
// Past the deadline, Shutdown returns and every remaining connection is
|
||||||
|
// closed. That is a deliberate choice of a bounded, reported failure
|
||||||
|
// over an unbounded wait.
|
||||||
|
func (s *Server) stop(ctx context.Context) error {
|
||||||
|
if s.httpServer == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
grace := s.params.Config.ShutdownGrace
|
||||||
|
|
||||||
|
stopCtx, cancel := context.WithTimeout(ctx, grace)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
s.log.Info("http shutting down", "grace", grace.String())
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
err := s.httpServer.Shutdown(stopCtx)
|
||||||
|
if err != nil {
|
||||||
|
// Connections were still open at the deadline. Report it —
|
||||||
|
// silence here would hide requests that were cut off — but do
|
||||||
|
// not fail the stop sequence: there is nothing left to retry
|
||||||
|
// and the rest of the graph still has to close cleanly.
|
||||||
|
s.log.Error("http shutdown did not drain in time",
|
||||||
|
"error", err,
|
||||||
|
"waited", time.Since(start).Round(time.Millisecond).String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Info("http shutdown complete",
|
||||||
|
"waited", time.Since(start).Round(time.Millisecond).String(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,560 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/url"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/fx/fxtest"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/database"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/handlers"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/middleware"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/render"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/server"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
// formNameField is the widget form's name input; refererHeader is the
|
||||||
|
// header gorilla/csrf checks on every state-changing request.
|
||||||
|
const (
|
||||||
|
formNameField = "name"
|
||||||
|
refererHeader = "Referer"
|
||||||
|
)
|
||||||
|
|
||||||
|
// response is a finished exchange: status, headers and the body, read
|
||||||
|
// and closed before the helper returns. Handing the tests a value
|
||||||
|
// rather than an *http.Response means no test can leak a connection by
|
||||||
|
// forgetting to close one.
|
||||||
|
type response struct {
|
||||||
|
status int
|
||||||
|
header http.Header
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// instance is a running service: a real listener on an ephemeral port,
|
||||||
|
// a real sqlite database in a temp dir, and the whole middleware chain.
|
||||||
|
// Nothing is stubbed, because the things most likely to be wrong —
|
||||||
|
// middleware order, route registration, embedded assets — are exactly
|
||||||
|
// what a stub would paper over.
|
||||||
|
type instance struct {
|
||||||
|
base string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// testConfig is the configuration every instance starts from.
|
||||||
|
func testConfig(t *testing.T) *config.Config {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
return &config.Config{
|
||||||
|
// Port 0: the OS picks a free one, so parallel tests do not
|
||||||
|
// fight over a fixed port.
|
||||||
|
Port: 0,
|
||||||
|
DataDir: dir,
|
||||||
|
DBPath: filepath.Join(dir, "test.db"),
|
||||||
|
Debug: true,
|
||||||
|
HSTS: false,
|
||||||
|
MaxRequestBody: 1 << 20,
|
||||||
|
RequestTimeout: 5 * time.Second,
|
||||||
|
ShutdownGrace: 2 * time.Second,
|
||||||
|
CSRFKeyEphemeral: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// build constructs the graph by hand. fx would do the same wiring; done
|
||||||
|
// explicitly, a constructor that starts needing a new dependency shows
|
||||||
|
// up here as a compile error rather than as a runtime resolution
|
||||||
|
// failure inside a container.
|
||||||
|
func build(t *testing.T, lc *fxtest.Lifecycle, cfg *config.Config) *server.Server {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
g := &globals.Globals{Appname: "simplexcalc", Version: "test", Buildarch: "amd64"}
|
||||||
|
|
||||||
|
log, err := logger.New(lc, logger.Params{Globals: g, Output: io.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := database.New(lc, database.Params{Config: cfg, Logger: log})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sentry, err := telemetry.NewSentry(lc, telemetry.SentryParams{
|
||||||
|
Config: cfg, Globals: g, Logger: log,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sentry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metrics, err := telemetry.NewMetrics(telemetry.MetricsParams{Config: cfg})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer, err := render.New(lc, render.Params{Globals: g})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("render: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mw, err := middleware.New(lc, middleware.Params{
|
||||||
|
Config: cfg, Logger: log, Sentry: sentry, Metrics: metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("middleware: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := handlers.New(lc, handlers.Params{
|
||||||
|
Config: cfg, Globals: g, Logger: log,
|
||||||
|
Database: db, Renderer: renderer, Sentry: sentry,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("handlers: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv, err := server.New(lc, server.Params{
|
||||||
|
Logger: log, Globals: g, Config: cfg,
|
||||||
|
Middleware: mw, Handlers: h, Metrics: metrics,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return srv
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInstance starts the service and registers its shutdown.
|
||||||
|
func newInstance(t *testing.T, mutate func(*config.Config)) *instance {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := testConfig(t)
|
||||||
|
if mutate != nil {
|
||||||
|
mutate(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
lc := fxtest.NewLifecycle(t)
|
||||||
|
srv := build(t, lc, cfg)
|
||||||
|
|
||||||
|
lc.RequireStart()
|
||||||
|
t.Cleanup(lc.RequireStop)
|
||||||
|
|
||||||
|
jar, err := cookiejar.New(nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cookie jar: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The listener binds the unspecified address, so Addr() reads
|
||||||
|
// "[::]:PORT". Dialling that works, but a cookie jar keyed on "::"
|
||||||
|
// does not, and the CSRF flow depends on cookies coming back — so
|
||||||
|
// the tests talk to the loopback address explicitly.
|
||||||
|
_, port, err := net.SplitHostPort(srv.Addr())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parsing listen address %q: %v", srv.Addr(), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &instance{
|
||||||
|
base: "http://127.0.0.1:" + port,
|
||||||
|
client: &http.Client{
|
||||||
|
Jar: jar,
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
// The redirect after a successful POST is under test, so
|
||||||
|
// it must not be followed silently.
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// host is the Host header value the service is reached by.
|
||||||
|
func (i *instance) host() string {
|
||||||
|
return strings.TrimPrefix(i.base, "http://")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *instance) get(t *testing.T, path string) response {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, i.base+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building GET %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.do(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
// post submits a form the way a browser does — including the Referer,
|
||||||
|
// which gorilla/csrf checks against the request's Host on every
|
||||||
|
// state-changing request. Omitting it is itself a CSRF rejection, so a
|
||||||
|
// test that leaves it out proves nothing about the token.
|
||||||
|
func (i *instance) post(t *testing.T, path string, form url.Values) response {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return i.postWithHeaders(t, path, form, map[string]string{
|
||||||
|
refererHeader: i.base + "/",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// postWithHeaders is post with the headers stated explicitly, for the
|
||||||
|
// reverse-proxy cases.
|
||||||
|
func (i *instance) postWithHeaders(
|
||||||
|
t *testing.T, path string, form url.Values, headers map[string]string,
|
||||||
|
) response {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(
|
||||||
|
t.Context(), http.MethodPost, i.base+path, strings.NewReader(form.Encode()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building POST %s: %v", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
for k, v := range headers {
|
||||||
|
req.Header.Set(k, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
return i.do(t, req)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (i *instance) do(t *testing.T, req *http.Request) response {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
resp, err := i.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%s %s: %v", req.Method, req.URL.Path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
closeErr := resp.Body.Close()
|
||||||
|
if closeErr != nil {
|
||||||
|
t.Errorf("closing response body: %v", closeErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("reading %s %s: %v", req.Method, req.URL.Path, readErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response{status: resp.StatusCode, header: resp.Header, body: string(body)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServesPageFromEmbeddedTemplate is one of the definition-of-done
|
||||||
|
// claims: a running process answers / from the template compiled into
|
||||||
|
// it.
|
||||||
|
func TestServesPageFromEmbeddedTemplate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
resp := i.get(t, "/")
|
||||||
|
|
||||||
|
if resp.status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, want := range []string{"<!doctype html>", "<nav>", "<footer>", "widgets ("} {
|
||||||
|
if !strings.Contains(resp.body, want) {
|
||||||
|
t.Errorf("page is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.header.Get("X-Content-Type-Options") != "nosniff" {
|
||||||
|
t.Error("security headers are missing from a normal page response")
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.header.Get(middleware.RequestIDHeader) == "" {
|
||||||
|
t.Error("no request id on the response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestServesEmbeddedStaticAsset, with the cache policy the route sets.
|
||||||
|
func TestServesEmbeddedStaticAsset(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
resp := i.get(t, "/static/css/style.css")
|
||||||
|
|
||||||
|
if resp.status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(resp.body, "--fg:") {
|
||||||
|
t.Error("the served asset is not the embedded stylesheet")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(resp.header.Get("Cache-Control"), "immutable") {
|
||||||
|
t.Errorf("Cache-Control = %q", resp.header.Get("Cache-Control"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestStaticDirectoriesAreNotListed: enumerating what the binary ships
|
||||||
|
// is a capability no caller needs.
|
||||||
|
func TestStaticDirectoriesAreNotListed(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
for _, path := range []string{"/static/", "/static/css/"} {
|
||||||
|
resp := i.get(t, path)
|
||||||
|
|
||||||
|
if resp.status != http.StatusNotFound {
|
||||||
|
t.Errorf("GET %s: status = %d, want 404", path, resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(resp.body, "style.css") {
|
||||||
|
t.Errorf("GET %s listed the directory contents", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHealthcheckReportsMigratedSchema proves the embedded migrations
|
||||||
|
// ran during startup, from a directory that was empty a moment ago.
|
||||||
|
func TestHealthcheckReportsMigratedSchema(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
resp := i.get(t, "/.well-known/healthcheck.json")
|
||||||
|
|
||||||
|
if resp.status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var health handlers.HealthResponse
|
||||||
|
|
||||||
|
err := json.Unmarshal([]byte(resp.body), &health)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decoding healthcheck: %v (body %q)", err, resp.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !health.OK || !health.DatabaseOK {
|
||||||
|
t.Errorf("healthcheck reports unhealthy: %+v", health)
|
||||||
|
}
|
||||||
|
|
||||||
|
if health.SchemaVersion != 1 {
|
||||||
|
t.Errorf("schema_version = %d, want 1", health.SchemaVersion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetricsEndpointIsServedAndGated covers both halves of the metrics
|
||||||
|
// requirement in one running service.
|
||||||
|
func TestMetricsEndpointIsServedAndGated(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("open when unconfigured", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
// One request first: the middleware records a request after
|
||||||
|
// the response is written, so a scrape that is the very first
|
||||||
|
// request to the process legitimately has no HTTP series in it
|
||||||
|
// yet.
|
||||||
|
i.get(t, "/")
|
||||||
|
|
||||||
|
resp := i.get(t, "/metrics")
|
||||||
|
|
||||||
|
if resp.status != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `http_requests_total{code="200",method="GET",route="/"}`
|
||||||
|
if !strings.Contains(resp.body, want) {
|
||||||
|
t.Errorf("the scrape does not report the request that preceded it:\n%s", resp.body)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("gated when configured", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, func(c *config.Config) {
|
||||||
|
c.MetricsUser = "scraper"
|
||||||
|
c.MetricsPassword = "hunter2"
|
||||||
|
})
|
||||||
|
|
||||||
|
resp := i.get(t, "/metrics")
|
||||||
|
if resp.status != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status = %d, want 401", resp.status)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnknownRouteRendersErrorPage: a 404 still carries the site's
|
||||||
|
// headers and chrome rather than net/http's bare text.
|
||||||
|
func TestUnknownRouteRendersErrorPage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
resp := i.get(t, "/no-such-page")
|
||||||
|
|
||||||
|
if resp.status != http.StatusNotFound {
|
||||||
|
t.Fatalf("status = %d, want 404", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(resp.body, "No such page") ||
|
||||||
|
!strings.Contains(resp.body, "<nav>") {
|
||||||
|
t.Errorf("404 is not the rendered error page:\n%s", resp.body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPanicIsAnsweredWith500 exercises the recovery middleware through
|
||||||
|
// the whole stack, on the debug-only route.
|
||||||
|
func TestPanicIsAnsweredWith500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
resp := i.get(t, "/debug/panic")
|
||||||
|
|
||||||
|
if resp.status != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want 500", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(resp.body, "deliberate panic") {
|
||||||
|
t.Error("the panic value reached the client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDebugRouteAbsentWithoutDebug: the panic route is a denial-of-
|
||||||
|
// service primitive, so it must not exist in a normal deployment.
|
||||||
|
func TestDebugRouteAbsentWithoutDebug(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, func(c *config.Config) { c.Debug = false })
|
||||||
|
|
||||||
|
resp := i.get(t, "/debug/panic")
|
||||||
|
if resp.status != http.StatusNotFound {
|
||||||
|
t.Errorf("status = %d, want 404 with DEBUG off", resp.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// csrfField finds the hidden token in a rendered form.
|
||||||
|
var csrfField = regexp.MustCompile(
|
||||||
|
`<input type="hidden" name="gorilla\.csrf\.Token" value="([^"]+)"`,
|
||||||
|
)
|
||||||
|
|
||||||
|
// token fetches the index page and returns the CSRF token its form
|
||||||
|
// carries.
|
||||||
|
func (i *instance) token(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
page := i.get(t, "/")
|
||||||
|
|
||||||
|
match := csrfField.FindStringSubmatch(page.body)
|
||||||
|
if match == nil {
|
||||||
|
t.Fatalf("no CSRF field in the rendered form:\n%s", page.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSRFProtectsStateChange is the whole point of the CSRF
|
||||||
|
// middleware: the same POST must be refused without a token and
|
||||||
|
// accepted with one.
|
||||||
|
func TestCSRFProtectsStateChange(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
// A well-formed cross-site request: everything a browser would
|
||||||
|
// send except the token.
|
||||||
|
resp := i.post(t, "/widgets", url.Values{formNameField: {"unauthorised"}})
|
||||||
|
if resp.status != http.StatusForbidden {
|
||||||
|
t.Fatalf("POST without a CSRF token: status = %d, want 403", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp = i.post(t, "/widgets", url.Values{
|
||||||
|
formNameField: {"authorised"},
|
||||||
|
"size": {"4KiB"},
|
||||||
|
"gorilla.csrf.Token": {i.token(t)},
|
||||||
|
})
|
||||||
|
|
||||||
|
if resp.status != http.StatusSeeOther {
|
||||||
|
t.Fatalf("POST with a CSRF token: status = %d, want 303", resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And the write actually happened.
|
||||||
|
after := i.get(t, "/")
|
||||||
|
if !strings.Contains(after.body, "authorised") {
|
||||||
|
t.Error("the created widget is not on the page")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(after.body, "4.0 KiB") {
|
||||||
|
t.Error("the size was not parsed and rendered")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(after.body, "unauthorised") {
|
||||||
|
t.Error("the rejected POST created a widget anyway")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSRFBehindTLSTerminatingProxy is the deployment shape this
|
||||||
|
// service actually runs in: the browser speaks HTTPS to a proxy and the
|
||||||
|
// proxy speaks plaintext HTTP here, saying so with X-Forwarded-Proto.
|
||||||
|
// The token must still be accepted, with the stricter TLS-side Referer
|
||||||
|
// rules applied to the browser's real scheme rather than to the
|
||||||
|
// connection's.
|
||||||
|
func TestCSRFBehindTLSTerminatingProxy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, nil)
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
formNameField: {"through the proxy"},
|
||||||
|
"gorilla.csrf.Token": {i.token(t)},
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cleartext Referer on a connection the proxy says was HTTPS is
|
||||||
|
// exactly the machine-in-the-middle case the strict check exists
|
||||||
|
// for, and must be refused.
|
||||||
|
resp := i.postWithHeaders(t, "/widgets", form, map[string]string{
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
refererHeader: "http://" + i.host() + "/",
|
||||||
|
})
|
||||||
|
if resp.status != http.StatusForbidden {
|
||||||
|
t.Errorf("cleartext referer under X-Forwarded-Proto=https: status = %d, want 403",
|
||||||
|
resp.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The genuine article: a same-origin HTTPS referer.
|
||||||
|
resp = i.postWithHeaders(t, "/widgets", form, map[string]string{
|
||||||
|
"X-Forwarded-Proto": "https",
|
||||||
|
refererHeader: "https://" + i.host() + "/",
|
||||||
|
})
|
||||||
|
if resp.status != http.StatusSeeOther {
|
||||||
|
t.Errorf("valid submission through a TLS-terminating proxy: status = %d, want 303",
|
||||||
|
resp.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestOversizedRequestIsRefused: the body cap is wired into the running
|
||||||
|
// chain, not just unit-tested in isolation.
|
||||||
|
func TestOversizedRequestIsRefused(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
i := newInstance(t, func(c *config.Config) { c.MaxRequestBody = 1024 })
|
||||||
|
|
||||||
|
// The cap is enforced ahead of CSRF in the chain, so an oversized
|
||||||
|
// body is refused as oversized rather than as untokened.
|
||||||
|
resp := i.post(t, "/widgets",
|
||||||
|
url.Values{formNameField: {strings.Repeat("x", 4096)}})
|
||||||
|
|
||||||
|
if resp.status != http.StatusRequestEntityTooLarge {
|
||||||
|
t.Errorf("status = %d, want 413", resp.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// filesOnly wraps an http.FileSystem so that directories do not exist as
|
||||||
|
// far as http.FileServer is concerned. FileServer asks the filesystem for
|
||||||
|
// the directory first and generates its index from what it gets back;
|
||||||
|
// refusing the Open is therefore the whole control, and it leaves the
|
||||||
|
// path handling, content sniffing and range support of FileServer intact.
|
||||||
|
//
|
||||||
|
// Enumerating what a binary ships is a capability no caller needs, and
|
||||||
|
// the embedded set grows as a project seeded from this template adds to
|
||||||
|
// it.
|
||||||
|
type filesOnly struct {
|
||||||
|
inner http.FileSystem
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open serves a file and refuses a directory with fs.ErrNotExist, which
|
||||||
|
// http.FileServer maps to 404 — the same answer a path that is not
|
||||||
|
// embedded at all gets.
|
||||||
|
func (f filesOnly) Open(name string) (http.File, error) {
|
||||||
|
file, err := f.inner.Open(name)
|
||||||
|
if err != nil {
|
||||||
|
// Unwrapped on purpose: http.FileServer inspects this error,
|
||||||
|
// and wrapping hides fs.ErrNotExist from it.
|
||||||
|
//nolint:wrapcheck // see above.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
//nolint:wrapcheck // as above.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.IsDir() {
|
||||||
|
_ = file.Close()
|
||||||
|
|
||||||
|
return nil, fs.ErrNotExist
|
||||||
|
}
|
||||||
|
|
||||||
|
return file, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// durationBuckets span a fast in-process handler through a slow
|
||||||
|
// upstream call. Prometheus's defaults top out at 10s, which hides the
|
||||||
|
// tail this service's request timeout permits.
|
||||||
|
//
|
||||||
|
//nolint:gochecknoglobals // a bucket list is a declaration, not mutable state.
|
||||||
|
var durationBuckets = []float64{
|
||||||
|
0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30,
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricsParams defines dependencies for Metrics.
|
||||||
|
type MetricsParams struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Config *config.Config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics owns the registry and the HTTP series. A private registry,
|
||||||
|
// not the global default: what this process exports is then exactly
|
||||||
|
// what this code registered, and a linked library cannot quietly add to
|
||||||
|
// it.
|
||||||
|
type Metrics struct {
|
||||||
|
registry *prometheus.Registry
|
||||||
|
|
||||||
|
requests *prometheus.CounterVec
|
||||||
|
duration *prometheus.HistogramVec
|
||||||
|
inflight prometheus.Gauge
|
||||||
|
|
||||||
|
user string
|
||||||
|
password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetrics builds the registry and registers the collectors.
|
||||||
|
func NewMetrics(params MetricsParams) (*Metrics, error) {
|
||||||
|
m := &Metrics{
|
||||||
|
registry: prometheus.NewRegistry(),
|
||||||
|
user: params.Config.MetricsUser,
|
||||||
|
password: params.Config.MetricsPassword,
|
||||||
|
}
|
||||||
|
|
||||||
|
m.requests = prometheus.NewCounterVec(
|
||||||
|
prometheus.CounterOpts{
|
||||||
|
Name: "http_requests_total",
|
||||||
|
Help: "Total HTTP requests by method, route pattern and status code.",
|
||||||
|
},
|
||||||
|
// The route PATTERN, never the path: labelling by path turns
|
||||||
|
// every distinct URL into a new time series, and a crawler
|
||||||
|
// then owns the memory of the process.
|
||||||
|
[]string{"method", "route", "code"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.duration = prometheus.NewHistogramVec(
|
||||||
|
prometheus.HistogramOpts{
|
||||||
|
Name: "http_request_duration_seconds",
|
||||||
|
Help: "HTTP request duration by method and route pattern.",
|
||||||
|
Buckets: durationBuckets,
|
||||||
|
},
|
||||||
|
[]string{"method", "route"},
|
||||||
|
)
|
||||||
|
|
||||||
|
m.inflight = prometheus.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "http_requests_in_flight",
|
||||||
|
Help: "HTTP requests currently being served.",
|
||||||
|
})
|
||||||
|
|
||||||
|
m.registry.MustRegister(
|
||||||
|
m.requests,
|
||||||
|
m.duration,
|
||||||
|
m.inflight,
|
||||||
|
collectors.NewGoCollector(),
|
||||||
|
collectors.NewProcessCollector(collectors.ProcessCollectorOpts{}),
|
||||||
|
)
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Observe records one finished request.
|
||||||
|
func (m *Metrics) Observe(method, route, code string, d time.Duration) {
|
||||||
|
m.requests.WithLabelValues(method, route, code).Inc()
|
||||||
|
m.duration.WithLabelValues(method, route).Observe(d.Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// InFlightAdd adjusts the in-flight gauge.
|
||||||
|
func (m *Metrics) InFlightAdd(delta float64) {
|
||||||
|
m.inflight.Add(delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registry exposes the registry so tests can gather what was recorded.
|
||||||
|
func (m *Metrics) Registry() *prometheus.Registry {
|
||||||
|
return m.registry
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthRequired reports whether /metrics is credential-gated.
|
||||||
|
func (m *Metrics) AuthRequired() bool {
|
||||||
|
return m.user != "" && m.password != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler serves the exposition format, behind HTTP basic auth when
|
||||||
|
// credentials are configured.
|
||||||
|
//
|
||||||
|
// Metrics are not public: they leak route names, traffic volume,
|
||||||
|
// version and process memory layout. When no credentials are set the
|
||||||
|
// endpoint is served open, which is correct for a private network and
|
||||||
|
// documented as such in the README; config refuses the half-configured
|
||||||
|
// case, so "open" is always something the operator chose rather than
|
||||||
|
// something a typo produced.
|
||||||
|
func (m *Metrics) Handler() http.Handler {
|
||||||
|
h := promhttp.HandlerFor(m.registry, promhttp.HandlerOpts{
|
||||||
|
// A collector that errors should not take the scrape down
|
||||||
|
// with a 500 the operator has to go and interpret.
|
||||||
|
ErrorHandling: promhttp.ContinueOnError,
|
||||||
|
})
|
||||||
|
|
||||||
|
if !m.AuthRequired() {
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.basicAuth(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// basicAuth gates h. Comparison is over SHA-256 digests through
|
||||||
|
// subtle.ConstantTimeCompare: comparing the raw strings would leak the
|
||||||
|
// credential length and the position of the first wrong byte through
|
||||||
|
// timing, and hashing first makes the comparison fixed-width.
|
||||||
|
func (m *Metrics) basicAuth(h http.Handler) http.Handler {
|
||||||
|
wantUser := sha256.Sum256([]byte(m.user))
|
||||||
|
wantPass := sha256.Sum256([]byte(m.password))
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if ok {
|
||||||
|
gotUser := sha256.Sum256([]byte(user))
|
||||||
|
gotPass := sha256.Sum256([]byte(pass))
|
||||||
|
|
||||||
|
userOK := subtle.ConstantTimeCompare(gotUser[:], wantUser[:]) == 1
|
||||||
|
passOK := subtle.ConstantTimeCompare(gotPass[:], wantPass[:]) == 1
|
||||||
|
|
||||||
|
if userOK && passOK {
|
||||||
|
h.ServeHTTP(w, r)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="metrics", charset="UTF-8"`)
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package telemetry_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/telemetry"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMetrics(t *testing.T, user, password string) *telemetry.Metrics {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
m, err := telemetry.NewMetrics(telemetry.MetricsParams{
|
||||||
|
Config: &config.Config{MetricsUser: user, MetricsPassword: password},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetricsRequireCredentialsWhenConfigured: an exposition endpoint
|
||||||
|
// leaks route names, traffic volume and process layout, so credentials
|
||||||
|
// have to actually be enforced.
|
||||||
|
func TestMetricsRequireCredentialsWhenConfigured(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := newMetrics(t, user, pass)
|
||||||
|
|
||||||
|
if !m.AuthRequired() {
|
||||||
|
t.Fatal("credentials are configured but AuthRequired is false")
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
unauthorized = http.StatusUnauthorized
|
||||||
|
ok = http.StatusOK
|
||||||
|
)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
user, pass string
|
||||||
|
useAuth bool
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{name: "no credentials", want: unauthorized},
|
||||||
|
{name: "wrong password", user: user, pass: "no", useAuth: true, want: unauthorized},
|
||||||
|
{name: "wrong user", user: "nobody", pass: pass, useAuth: true, want: unauthorized},
|
||||||
|
{name: "correct", user: user, pass: pass, useAuth: true, want: ok},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := scrapeReq(t)
|
||||||
|
if tc.useAuth {
|
||||||
|
req.SetBasicAuth(tc.user, tc.pass)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Handler().ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Errorf("status = %d, want %d", w.Code, tc.want)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tc.want == http.StatusUnauthorized {
|
||||||
|
if w.Header().Get("WWW-Authenticate") == "" {
|
||||||
|
t.Error("a 401 with no WWW-Authenticate gives the client nothing to do")
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(w.Body.String(), "http_requests_total") {
|
||||||
|
t.Error("metrics were served in the body of a 401")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMetricsOpenWhenNoCredentials documents the other half: with
|
||||||
|
// nothing configured the endpoint is open, which config permits only
|
||||||
|
// when BOTH values are absent.
|
||||||
|
func TestMetricsOpenWhenNoCredentials(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := newMetrics(t, "", "")
|
||||||
|
|
||||||
|
if m.AuthRequired() {
|
||||||
|
t.Fatal("no credentials configured but AuthRequired is true")
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Handler().ServeHTTP(w, scrapeReq(t))
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestObservedRequestsAreExported: the middleware records through
|
||||||
|
// Observe, and what it records has to come back out of the endpoint.
|
||||||
|
func TestObservedRequestsAreExported(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
m := newMetrics(t, "", "")
|
||||||
|
|
||||||
|
m.Observe(http.MethodGet, "/widgets/{id}", "200", 25*time.Millisecond)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
m.Handler().ServeHTTP(w, scrapeReq(t))
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
for _, want := range []string{
|
||||||
|
`http_requests_total{code="200",method="GET",route="/widgets/{id}"} 1`,
|
||||||
|
"http_request_duration_seconds_bucket",
|
||||||
|
"go_goroutines", // the Go collector is registered
|
||||||
|
} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("exposition output is missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSentryDisabledWithoutDSN: every method must be safe with no DSN,
|
||||||
|
// because that is how the service runs in development and in tests.
|
||||||
|
func TestSentryDisabledWithoutDSN(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
s, err := telemetry.NewSentry(nil, telemetry.SentryParams{
|
||||||
|
Config: &config.Config{},
|
||||||
|
Globals: testGlobals(),
|
||||||
|
Logger: testLogger(t),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building sentry: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.Enabled() {
|
||||||
|
t.Error("sentry reports enabled with no DSN")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not panic.
|
||||||
|
s.CaptureError(nil)
|
||||||
|
s.CaptureError(errTest)
|
||||||
|
s.CapturePanic("boom", []byte("stack"))
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
// Package telemetry owns error reporting (Sentry) and metrics
|
||||||
|
// (Prometheus). Both are optional at runtime and neither is allowed to
|
||||||
|
// take the process down: a monitoring backend that is unreachable must
|
||||||
|
// not stop the service it monitors.
|
||||||
|
package telemetry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/getsentry/sentry-go"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/config"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// flushTimeout bounds how long shutdown waits for queued events to
|
||||||
|
// reach Sentry. Exceeding it drops the tail rather than hanging the
|
||||||
|
// stop sequence.
|
||||||
|
const flushTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
// SentryParams defines dependencies for Sentry.
|
||||||
|
type SentryParams struct {
|
||||||
|
fx.In
|
||||||
|
|
||||||
|
Config *config.Config
|
||||||
|
Globals *globals.Globals
|
||||||
|
Logger *logger.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sentry wraps the client. When SENTRY_DSN is unset the wrapper still
|
||||||
|
// exists and every method is a no-op, so no caller needs a nil check
|
||||||
|
// and no caller behaves differently in development.
|
||||||
|
type Sentry struct {
|
||||||
|
enabled bool
|
||||||
|
log *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSentry initialises the client if a DSN is configured. A DSN that
|
||||||
|
// is present but malformed has already failed config parsing; a DSN the
|
||||||
|
// client itself rejects is logged and reporting stays off, because a
|
||||||
|
// telemetry backend is not a reason to refuse to serve.
|
||||||
|
func NewSentry(lc fx.Lifecycle, params SentryParams) (*Sentry, error) {
|
||||||
|
s := &Sentry{log: params.Logger.Get()}
|
||||||
|
|
||||||
|
if params.Config.SentryDSN == "" {
|
||||||
|
s.log.Info("sentry disabled", "reason", "no DSN configured")
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := sentry.Init(sentry.ClientOptions{
|
||||||
|
Dsn: params.Config.SentryDSN,
|
||||||
|
Environment: params.Config.SentryEnvironment,
|
||||||
|
Release: params.Globals.Appname + "@" + params.Globals.Version,
|
||||||
|
// Panics are reported explicitly by the recovery middleware,
|
||||||
|
// which also has to answer the request; letting the SDK
|
||||||
|
// re-raise them would take the process down.
|
||||||
|
Debug: params.Config.Debug,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("sentry init failed; error reporting is off", "error", err)
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
s.enabled = true
|
||||||
|
|
||||||
|
s.log.Info("sentry enabled", "environment", params.Config.SentryEnvironment)
|
||||||
|
|
||||||
|
lc.Append(fx.Hook{
|
||||||
|
OnStop: func(_ context.Context) error {
|
||||||
|
sentry.Flush(flushTimeout)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether events are actually being sent.
|
||||||
|
func (s *Sentry) Enabled() bool {
|
||||||
|
return s.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// CaptureError reports an error, and always logs it. Logging is not
|
||||||
|
// conditional on Sentry being on: the log is the record of record, and
|
||||||
|
// Sentry is a convenience on top of it.
|
||||||
|
func (s *Sentry) CaptureError(err error) {
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.log.Error("captured error", "error", err)
|
||||||
|
|
||||||
|
if s.enabled {
|
||||||
|
sentry.CaptureException(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CapturePanic reports a recovered panic value with its stack.
|
||||||
|
func (s *Sentry) CapturePanic(v any, stack []byte) {
|
||||||
|
s.log.Error("recovered panic", "panic", fmt.Sprint(v), "stack", string(stack))
|
||||||
|
|
||||||
|
if s.enabled {
|
||||||
|
sentry.CurrentHub().Recover(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package telemetry_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/globals"
|
||||||
|
"sneak.berlin/go/simplexcalc/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scrapeReq is a request to /metrics carrying the test's context.
|
||||||
|
func scrapeReq(t *testing.T) *http.Request {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/metrics", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// errTest is a stand-in error for the capture paths.
|
||||||
|
var errTest = errors.New("test error")
|
||||||
|
|
||||||
|
// The metrics credentials used across these tests.
|
||||||
|
const (
|
||||||
|
user = "scraper"
|
||||||
|
pass = "hunter2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testGlobals() *globals.Globals {
|
||||||
|
return &globals.Globals{Appname: "simplexcalc", Version: "test", Buildarch: "amd64"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogger(t *testing.T) *logger.Logger {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
log, err := logger.New(nil, logger.Params{Globals: testGlobals(), Output: io.Discard})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("building logger: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return log
|
||||||
|
}
|
||||||
Executable
+166
@@ -0,0 +1,166 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/assert-context-complete: fail unless the build context a stage
|
||||||
|
# was given contained every source file the repository has.
|
||||||
|
#
|
||||||
|
# Usage: script/assert-context-complete LOG STAGE EXPECTED
|
||||||
|
#
|
||||||
|
# LOG must be `docker build --progress=plain` output. EXPECTED is a list
|
||||||
|
# of repo-relative paths, one per line, as script/repo-source-manifest
|
||||||
|
# prints it.
|
||||||
|
#
|
||||||
|
# script/assert-step-ran answers "did the tool execute". This answers
|
||||||
|
# the other half: "was the tool given the tree". They are different
|
||||||
|
# questions, and a green build can satisfy the first while failing the
|
||||||
|
# second — a .dockerignore entry, or a COPY that brings in less than the
|
||||||
|
# whole tree, removes files from the context silently, and golangci-lint
|
||||||
|
# then genuinely runs, genuinely examines what it was handed, and
|
||||||
|
# genuinely reports `0 issues.` over a repo that has a violation in it.
|
||||||
|
# `go test ./...` likewise never runs a package that did not arrive.
|
||||||
|
#
|
||||||
|
# How it knows: each source-consuming stage emits, right after its
|
||||||
|
# `COPY . .`, an inventory of the Go files actually present —
|
||||||
|
#
|
||||||
|
# context-manifest-begin
|
||||||
|
# context-file: cmd/simplexcalc/main.go
|
||||||
|
# ...
|
||||||
|
# context-manifest-end
|
||||||
|
#
|
||||||
|
# — and this compares that against EXPECTED, which comes from the git
|
||||||
|
# index rather than from the same build. The two sources are
|
||||||
|
# independent: .dockerignore decides the first and cannot touch the
|
||||||
|
# second. An expectation read from the evidence would prove nothing.
|
||||||
|
#
|
||||||
|
# The manifest lines are only believed under the same discipline
|
||||||
|
# script/assert-step-ran applies to a tool's success line: they must be
|
||||||
|
# attributed to the id of a step whose header is "#ID [STAGE n/m] CMD"
|
||||||
|
# with CMD matching the manifest command, which BuildKit reported DONE,
|
||||||
|
# and both sentinels must be present. So another step's output does not
|
||||||
|
# count, a header does not count, a cached step (which writes nothing)
|
||||||
|
# does not count, and a truncated log fails rather than passing with a
|
||||||
|
# short list.
|
||||||
|
#
|
||||||
|
# Only one direction is checked: everything the repo has must have
|
||||||
|
# arrived. Files in the context that git does not track are not a
|
||||||
|
# failure — untracked local work is normal and hides nothing.
|
||||||
|
#
|
||||||
|
# What this does not cover: a Dockerfile edit to the manifest step
|
||||||
|
# itself, and evidence forged inside a matched command. Both are visible
|
||||||
|
# in the Dockerfile in plain sight, and both are equally outside
|
||||||
|
# script/assert-step-ran. This is not an exhaustive list of ways a green
|
||||||
|
# run can be untrue; it is the ones known.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "script/assert-context-complete: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ $# -eq 3 ] || die "usage: $0 LOG STAGE EXPECTED"
|
||||||
|
[ -r "$1" ] || die "cannot read $1"
|
||||||
|
[ -r "$3" ] || die "cannot read $3"
|
||||||
|
|
||||||
|
# The stage and the expected-list path travel in the environment, not in
|
||||||
|
# -v: awk expands escape sequences in a -v assignment.
|
||||||
|
ACC_STAGE="$2" ACC_EXPECTED="$3" awk '
|
||||||
|
function fail(msg) {
|
||||||
|
printf "script/assert-context-complete: %s\n", msg | "cat 1>&2"
|
||||||
|
close("cat 1>&2")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# The package a missing file belongs to — its directory, which is what a
|
||||||
|
# reader needs named. A bare "." for a root-level file reads as a typo
|
||||||
|
# next to the sentence punctuation, so it is spelled out.
|
||||||
|
function pkgof(path, i) {
|
||||||
|
i = length(path)
|
||||||
|
while (i > 0 && substr(path, i, 1) != "/") i--
|
||||||
|
return i == 0 ? "the repository root" : substr(path, 1, i - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function preview(list, n, limit, i, out) {
|
||||||
|
for (i = 1; i <= n && i <= limit; i++)
|
||||||
|
out = out (i > 1 ? ", " : "") list[i]
|
||||||
|
if (n > limit)
|
||||||
|
out = out sprintf(", and %d more", n - limit)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
BEGIN {
|
||||||
|
stage = ENVIRON["ACC_STAGE"]
|
||||||
|
|
||||||
|
# Fixed by the protocol the Dockerfile emits, so callers cannot
|
||||||
|
# drift from it.
|
||||||
|
step = "^RUN echo context-manifest-begin"
|
||||||
|
beginmark = "context-manifest-begin"
|
||||||
|
endmark = "context-manifest-end"
|
||||||
|
prefix = "context-file: "
|
||||||
|
|
||||||
|
expected_path = ENVIRON["ACC_EXPECTED"]
|
||||||
|
while ((getline line < expected_path) > 0)
|
||||||
|
if (line != "") expected[++nexpected] = line
|
||||||
|
close(expected_path)
|
||||||
|
|
||||||
|
if (nexpected == 0)
|
||||||
|
fail(sprintf("the expected-file list (%s) is empty, so any build context would satisfy this check", expected_path))
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every progress line is "#ID " and then a step header, a status, or one
|
||||||
|
# line the step itself wrote.
|
||||||
|
/^#[0-9]+ / {
|
||||||
|
id = substr($1, 2)
|
||||||
|
rest = substr($0, length($1) + 2)
|
||||||
|
|
||||||
|
if (rest == "CACHED") { cached[id] = 1; next }
|
||||||
|
if (rest ~ /^DONE /) { done[id] = 1; next }
|
||||||
|
|
||||||
|
# Header: "[STAGE n/m] CMD", or "[PLATFORM STAGE n/m] CMD" when the
|
||||||
|
# build names a platform. Bracketed spans with no n/m are BuildKit
|
||||||
|
# internals, not steps.
|
||||||
|
if (substr(rest, 1, 1) == "[") {
|
||||||
|
p = index(rest, "] ")
|
||||||
|
if (p == 0) next
|
||||||
|
n = split(substr(rest, 2, p - 2), part, " ")
|
||||||
|
if (n < 2 || part[n] !~ /^[0-9]+\/[0-9]+$/) next
|
||||||
|
if (part[n - 1] != stage) next
|
||||||
|
if (substr(rest, p + 2) ~ step) matched[id] = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
# Output: "#ID 0.31 <the line the step wrote>".
|
||||||
|
if (rest ~ /^[0-9]+[.][0-9]+ /) {
|
||||||
|
sub(/^[0-9]+[.][0-9]+ /, "", rest)
|
||||||
|
if (rest == beginmark) begun[id] = 1
|
||||||
|
else if (rest == endmark) ended[id] = 1
|
||||||
|
else if (index(rest, prefix) == 1)
|
||||||
|
arrived[id SUBSEP substr(rest, length(prefix) + 1)] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
for (id in matched) {
|
||||||
|
nmatched++
|
||||||
|
if (cached[id]) ncached++
|
||||||
|
if (done[id] && begun[id] && ended[id]) chosen = id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nmatched == 0)
|
||||||
|
fail(sprintf("the build ran no step matching /%s/ in stage \"%s\", so the build context was never inventoried. Either the stage is not in the build graph — renamed, deleted, or nothing the final stage builds depends on it any more — or the manifest step was taken out of it", step, stage))
|
||||||
|
|
||||||
|
if (chosen == "" && ncached == nmatched)
|
||||||
|
fail(sprintf("every step matching /%s/ in stage \"%s\" was served from cache, so its inventory describes an earlier tree rather than this one; the cache bust for that stage is not taking effect", step, stage))
|
||||||
|
|
||||||
|
if (chosen == "")
|
||||||
|
fail(sprintf("a step matching /%s/ ran in stage \"%s\" but wrote no complete inventory between %s and %s. The log is truncated, the build was not run with --progress=plain, or that step no longer emits the manifest", step, stage, beginmark, endmark))
|
||||||
|
|
||||||
|
for (i = 1; i <= nexpected; i++) {
|
||||||
|
if (arrived[chosen SUBSEP expected[i]]) continue
|
||||||
|
missing[++nmissing] = expected[i]
|
||||||
|
pkg = pkgof(expected[i])
|
||||||
|
if (!(pkg in seenpkg)) { seenpkg[pkg] = 1; pkgs[++npkgs] = pkg }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nmissing == 0) exit 0
|
||||||
|
|
||||||
|
fail(sprintf("%d of the %d source files this repository tracks never reached the build context of stage \"%s\", so nothing examined them. Missing package(s): %s. Missing file(s): %s. A .dockerignore entry, or a COPY that brings in less than the whole tree, drops files silently: the tool still runs, still finds nothing wrong with what it was handed, and still reports success", nmissing, nexpected, stage, preview(pkgs, npkgs, 10), preview(missing, nmissing, 10)))
|
||||||
|
}
|
||||||
|
' "$1"
|
||||||
Executable
+125
@@ -0,0 +1,125 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/assert-step-ran: fail unless a docker build actually executed a
|
||||||
|
# named step, proved by output the step's own tool wrote.
|
||||||
|
#
|
||||||
|
# Usage: script/assert-step-ran LOG STAGE STEP_REGEX EVIDENCE_REGEX WHAT
|
||||||
|
#
|
||||||
|
# LOG must be `docker build --progress=plain` output. The default tty
|
||||||
|
# renderer rewrites lines in place and keeps only the tail of a step's
|
||||||
|
# output, so the flag is load-bearing wherever this is used.
|
||||||
|
#
|
||||||
|
# It passes only if some step in LOG satisfies all three of:
|
||||||
|
#
|
||||||
|
# 1. its header is "#ID [STAGE n/m] CMD" with CMD matching STEP_REGEX,
|
||||||
|
# 2. one of the lines that step wrote matches EVIDENCE_REGEX,
|
||||||
|
# 3. BuildKit reported "#ID DONE".
|
||||||
|
#
|
||||||
|
# This observes the build that happened; it parses no Dockerfile. A
|
||||||
|
# stage renamed, moved, made unreachable, deleted, split by a whitespace
|
||||||
|
# byte BuildKit treats as a separator, or an instruction rewritten —
|
||||||
|
# none of it can make this pass a build in which the step did not run,
|
||||||
|
# because a step that did not run wrote no output, and a step served
|
||||||
|
# from cache writes none either ("#ID CACHED", no output lines).
|
||||||
|
#
|
||||||
|
# The stage name in a header is BuildKit's label for that vertex, not a
|
||||||
|
# reading of the file being built. A vertex shared with an earlier build
|
||||||
|
# of a DIFFERENT Dockerfile is replayed under the label it was first
|
||||||
|
# recorded with, so a `Dockerfile.lint` run can print `[lint 1/8]` for
|
||||||
|
# the main `Dockerfile`'s lint stage. A borrowed label cannot produce a
|
||||||
|
# pass — a replayed vertex prints CACHED and writes no evidence, and a
|
||||||
|
# vertex that really executes really ran the command in the header — but
|
||||||
|
# it does mean a header alone proves nothing about which stages the file
|
||||||
|
# declares. Hence one failure message naming both causes rather than a
|
||||||
|
# split that would claim to know which.
|
||||||
|
#
|
||||||
|
# What this guard cannot see — not an exhaustive list of ways a green can
|
||||||
|
# be untrue, only the ones known:
|
||||||
|
#
|
||||||
|
# - Evidence forged inside the matched step, e.g.
|
||||||
|
# `RUN golangci-lint run ... || echo "0 issues."`. Condition 1 ties
|
||||||
|
# the evidence to a step whose own command is in the log, so the
|
||||||
|
# forgery has to be written into that command in the Dockerfile, in
|
||||||
|
# plain sight.
|
||||||
|
# This guards against a step falling silently out of the build. It does
|
||||||
|
# not certify that what ran examined everything it should have — a
|
||||||
|
# `.dockerignore` entry excluding a package makes the linter genuinely
|
||||||
|
# run and genuinely print `0 issues.` while a real violation sits
|
||||||
|
# unexamined in the repo. That is a different question, asked separately
|
||||||
|
# by script/assert-context-complete, which every caller of this script
|
||||||
|
# also runs.
|
||||||
|
#
|
||||||
|
# What it depends on — BuildKit's plain progress format and the tool's
|
||||||
|
# own success wording — fails the caller loudly if it drifts, because
|
||||||
|
# drift removes a match rather than creating one.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "script/assert-step-ran: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[ $# -eq 5 ] || die "usage: $0 LOG STAGE STEP_REGEX EVIDENCE_REGEX WHAT"
|
||||||
|
[ -r "$1" ] || die "cannot read $1"
|
||||||
|
|
||||||
|
# The regexes travel in the environment, not in -v: awk expands escape
|
||||||
|
# sequences in a -v assignment, which would eat a backslash before the
|
||||||
|
# regex ever sees it.
|
||||||
|
ASR_STAGE="$2" ASR_STEP="$3" ASR_EVIDENCE="$4" ASR_WHAT="$5" awk '
|
||||||
|
function fail(msg) {
|
||||||
|
printf "script/assert-step-ran: %s\n", msg | "cat 1>&2"
|
||||||
|
close("cat 1>&2")
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
BEGIN {
|
||||||
|
stage = ENVIRON["ASR_STAGE"]
|
||||||
|
step = ENVIRON["ASR_STEP"]
|
||||||
|
evidence = ENVIRON["ASR_EVIDENCE"]
|
||||||
|
what = ENVIRON["ASR_WHAT"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Every progress line is "#ID " and then a step header, a status, or one
|
||||||
|
# line the step itself wrote.
|
||||||
|
/^#[0-9]+ / {
|
||||||
|
id = substr($1, 2)
|
||||||
|
rest = substr($0, length($1) + 2)
|
||||||
|
|
||||||
|
if (rest == "CACHED") { cached[id] = 1; next }
|
||||||
|
if (rest ~ /^DONE /) { done[id] = 1; next }
|
||||||
|
|
||||||
|
# Header: "[STAGE n/m] CMD", or "[PLATFORM STAGE n/m] CMD" when the
|
||||||
|
# build names a platform. Bracketed spans with no n/m are BuildKit
|
||||||
|
# internals ("[internal] load build context"), not steps.
|
||||||
|
if (substr(rest, 1, 1) == "[") {
|
||||||
|
p = index(rest, "] ")
|
||||||
|
if (p == 0) next
|
||||||
|
n = split(substr(rest, 2, p - 2), part, " ")
|
||||||
|
if (n < 2 || part[n] !~ /^[0-9]+\/[0-9]+$/) next
|
||||||
|
if (part[n - 1] != stage) next
|
||||||
|
if (substr(rest, p + 2) ~ step) matched[id] = 1
|
||||||
|
next
|
||||||
|
}
|
||||||
|
|
||||||
|
# Output: "#ID 41.80 <the line the step wrote>".
|
||||||
|
if (rest ~ /^[0-9]+[.][0-9]+ /) {
|
||||||
|
sub(/^[0-9]+[.][0-9]+ /, "", rest)
|
||||||
|
if (rest ~ evidence) emitted[id] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
END {
|
||||||
|
for (id in matched) {
|
||||||
|
nmatched++
|
||||||
|
if (done[id] && emitted[id]) exit 0
|
||||||
|
if (cached[id]) ncached++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nmatched == 0)
|
||||||
|
fail(sprintf("the build ran no step matching /%s/ in stage \"%s\", so %s did not run. Either the stage is not in the build graph — renamed, deleted, or nothing the final stage builds depends on it any more — or it was built without that command among its steps", step, stage, what))
|
||||||
|
|
||||||
|
if (ncached == nmatched)
|
||||||
|
fail(sprintf("every step matching /%s/ in stage \"%s\" was served from cache, so %s did not run on this tree; the cache bust for that stage is not taking effect", step, stage, what))
|
||||||
|
|
||||||
|
fail(sprintf("a step matching /%s/ ran in stage \"%s\" but never wrote a line matching /%s/, so there is no evidence %s did the work; the command or the tool that produces that line has changed", step, stage, evidence, what))
|
||||||
|
}
|
||||||
|
' "$1"
|
||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/bootstrap: install all dependencies needed to build and develop
|
||||||
|
# this repo. Idempotent: every install is guarded by a check so already
|
||||||
|
# installed tools are skipped. Base tooling comes from nix, apt, brew,
|
||||||
|
# or apk (detected in that order); assumes NOTHING is present (not git,
|
||||||
|
# make, or go). The linter is NOT installed locally: golangci-lint runs
|
||||||
|
# via docker only (script/lint), pinned by image digest, so the only
|
||||||
|
# lint prerequisite is a working docker.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
PKGMGR=""
|
||||||
|
SUDO=""
|
||||||
|
|
||||||
|
detect_pkgmgr() {
|
||||||
|
[ -n "$PKGMGR" ] && return 0
|
||||||
|
if command -v nix-env >/dev/null 2>&1; then
|
||||||
|
PKGMGR="nix"
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
PKGMGR="apt"
|
||||||
|
elif command -v brew >/dev/null 2>&1; then
|
||||||
|
PKGMGR="brew"
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
PKGMGR="apk"
|
||||||
|
else
|
||||||
|
echo "bootstrap: no supported package manager (nix, apt, brew, apk)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "$PKGMGR" = "apt" ]; then
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
if [ "$(id -u)" != "0" ]; then
|
||||||
|
SUDO="sudo"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# pkg_install <nix-attr> <apt-pkg> <brew-formula> <apk-pkg>
|
||||||
|
pkg_install() {
|
||||||
|
detect_pkgmgr
|
||||||
|
case "$PKGMGR" in
|
||||||
|
nix) nix-env -iA "nixpkgs.$1" ;;
|
||||||
|
apt) $SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y "$2" ;;
|
||||||
|
brew) brew install "$3" ;;
|
||||||
|
apk) apk add --no-cache "$4" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
missing() {
|
||||||
|
! command -v "$1" >/dev/null 2>&1
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Base tooling
|
||||||
|
if missing git; then pkg_install git git git git; fi
|
||||||
|
if missing make; then pkg_install gnumake make make make; fi
|
||||||
|
|
||||||
|
# Go toolchain
|
||||||
|
if missing go; then pkg_install go golang go go; fi
|
||||||
|
|
||||||
|
# Linting runs via docker only (script/lint). Warn, don't fail:
|
||||||
|
# everything except `make lint` works without it.
|
||||||
|
if missing docker; then
|
||||||
|
echo "bootstrap: WARNING: docker not found; make lint and" >&2
|
||||||
|
echo "bootstrap: make docker require it. Install docker to" >&2
|
||||||
|
echo "bootstrap: run the linter." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Markdown/CSS/JS formatting uses prettier. script/prettier falls
|
||||||
|
# back to a pinned docker image when it is not on PATH, so this is
|
||||||
|
# a convenience, not a requirement.
|
||||||
|
if missing prettier && missing docker; then
|
||||||
|
echo "bootstrap: WARNING: neither prettier nor docker found;" >&2
|
||||||
|
echo "bootstrap: markdown formatting will be skipped." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
go mod download
|
||||||
|
|
||||||
|
echo "bootstrap complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/check: run all checks (test, lint, fmt-check). Our own
|
||||||
|
# extension to scripts-to-rule-them-all. Must not modify any files.
|
||||||
|
# Generic: usually needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/test"
|
||||||
|
"$SCRIPT_DIR/lint"
|
||||||
|
"$SCRIPT_DIR/fmt-check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+89
@@ -0,0 +1,89 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/cibuild: run the CI build. The Dockerfile runs the checks
|
||||||
|
# (gofmt, config verify, lint, test), so a successful build implies a
|
||||||
|
# green repo. The Gitea workflow runs this on push.
|
||||||
|
#
|
||||||
|
# Only if the checks actually ran, though, and a green `docker build` is
|
||||||
|
# no evidence that they did:
|
||||||
|
#
|
||||||
|
# 1. On an unchanged tree every layer comes from cache and the build
|
||||||
|
# exits 0 in under a second having executed nothing. Hence the two
|
||||||
|
# --no-cache-filter flags.
|
||||||
|
#
|
||||||
|
# 2. BuildKit silently ignores a --no-cache-filter naming a stage that
|
||||||
|
# does not exist, so a rename restores that green no-op.
|
||||||
|
#
|
||||||
|
# 3. BuildKit builds only the final stage's dependency graph. A stage
|
||||||
|
# nothing references is never built, and with no --target the final
|
||||||
|
# stage is whichever is last in the file — so appending a stage, or
|
||||||
|
# moving a reference into a stage that is itself unreachable, drops
|
||||||
|
# the checks out of the build while every reference to them is still
|
||||||
|
# there to read.
|
||||||
|
#
|
||||||
|
# Rather than predict any of that from the Dockerfile's text, this asks
|
||||||
|
# the finished build what it did: script/assert-step-ran requires that
|
||||||
|
# the log contain a lint step and a test step that ran, and that each
|
||||||
|
# wrote the line its own tool writes on success. Every trap above ends
|
||||||
|
# with the step absent from the log or served from cache, and both fail
|
||||||
|
# that assertion. See that script for what it does not cover.
|
||||||
|
#
|
||||||
|
# 4. A step that ran is not a step that saw the repo. .dockerignore, or
|
||||||
|
# a COPY narrower than the tree, removes files from the build
|
||||||
|
# context, and the linter then reports `0 issues.` over what is left
|
||||||
|
# while `go test ./...` never compiles the package that went missing.
|
||||||
|
# So each source-consuming stage inventories what reached it, and
|
||||||
|
# script/assert-context-complete compares that inventory against the
|
||||||
|
# git index — a source .dockerignore cannot reach.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
dockerfile=Dockerfile
|
||||||
|
lint_stage=lint
|
||||||
|
test_stage=builder
|
||||||
|
|
||||||
|
# `ok <pkg> 1.234s`: go test prints `(cached)` in place of the duration
|
||||||
|
# when it replays a result, so requiring the duration requires a package
|
||||||
|
# that was really exercised.
|
||||||
|
test_ran='^ok[[:space:]]+[^[:space:]]+[[:space:]]+[0-9]+[.][0-9]+s'
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/$("$SCRIPT_DIR/projectname")-cibuild.XXXXXX")"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||||
|
|
||||||
|
# --progress=plain is load-bearing: the assertions below read the
|
||||||
|
# steps' own output out of this log, and the tty renderer discards
|
||||||
|
# all but the tail of it. The exit status travels through a file
|
||||||
|
# because a pipeline's status is the last command's; `set +e` is
|
||||||
|
# what lets the recording line run at all, since errexit would
|
||||||
|
# otherwise abandon the subshell on a failing build. A missing file
|
||||||
|
# reads as failure.
|
||||||
|
(
|
||||||
|
set +e
|
||||||
|
docker build \
|
||||||
|
--progress=plain \
|
||||||
|
--no-cache-filter="$lint_stage" \
|
||||||
|
--no-cache-filter="$test_stage" \
|
||||||
|
-f "$dockerfile" . 2>&1
|
||||||
|
echo "$?" >"$tmp/status"
|
||||||
|
) | tee "$tmp/build.log"
|
||||||
|
|
||||||
|
status="$(cat "$tmp/status" 2>/dev/null || echo 1)"
|
||||||
|
[ "${status:-1}" -eq 0 ] || exit "${status:-1}"
|
||||||
|
|
||||||
|
script/assert-step-ran "$tmp/build.log" "$lint_stage" \
|
||||||
|
'^RUN golangci-lint run' '^0 issues[.]$' 'the linter'
|
||||||
|
script/assert-step-ran "$tmp/build.log" "$test_stage" \
|
||||||
|
'^RUN go test' "$test_ran" 'the tests'
|
||||||
|
|
||||||
|
# Both stages, separately: each has its own COPY, so an intact
|
||||||
|
# context in one is no evidence about the other.
|
||||||
|
script/repo-source-manifest >"$tmp/expected"
|
||||||
|
script/assert-context-complete "$tmp/build.log" "$lint_stage" "$tmp/expected"
|
||||||
|
script/assert-context-complete "$tmp/build.log" "$test_stage" "$tmp/expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
|
# Generic: needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+26
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt: format all files (writes). Go via gofmt, everything else
|
||||||
|
# (markdown, CSS, JS, YAML, JSON) via prettier with the repo's
|
||||||
|
# .prettierrc. Never hand-rolled substitutions: this script is the only
|
||||||
|
# sanctioned way to reformat the tree.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
gofmt -s -w .
|
||||||
|
|
||||||
|
if command -v goimports >/dev/null 2>&1; then
|
||||||
|
goimports -w .
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A missing runner (exit 2) is a warning here: script/prettier has
|
||||||
|
# already said so on stderr, and failing `make fmt` over it would
|
||||||
|
# block work that has nothing to do with markdown.
|
||||||
|
"$SCRIPT_DIR/prettier" --write . || [ $? -eq 2 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+25
@@ -0,0 +1,25 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/fmt-check: check formatting (read-only). Same scope as
|
||||||
|
# script/fmt, but fails instead of writing.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if [ -n "$(gofmt -s -l .)" ]; then
|
||||||
|
echo "gofmt needed on:"
|
||||||
|
gofmt -s -l .
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Exit 2 means no prettier and no docker: reported by
|
||||||
|
# script/prettier on stderr and not treated as a failure, so this
|
||||||
|
# check still runs on a machine that has neither. The container
|
||||||
|
# build is the gate that always has both.
|
||||||
|
"$SCRIPT_DIR/prettier" --check . || [ $? -eq 2 ]
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+16
@@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/install-precommit: install the git pre-commit hook that runs
|
||||||
|
# script/precommit. Our own extension to scripts-to-rule-them-all.
|
||||||
|
# Generic: needs no adaptation.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
printf '#!/bin/sh\nset -e\nscript/precommit\n' > .git/hooks/pre-commit
|
||||||
|
chmod +x .git/hooks/pre-commit
|
||||||
|
echo "pre-commit hook installed: runs script/precommit"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+71
@@ -0,0 +1,71 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/lint: run the linter. golangci-lint is never installed locally: it
|
||||||
|
# runs via docker only, one way, everywhere.
|
||||||
|
#
|
||||||
|
# Traps, each of which yields a green run over an unlinted or partly linted
|
||||||
|
# tree:
|
||||||
|
#
|
||||||
|
# 1. A bare `docker build -f Dockerfile.lint .` serves the lint layer from
|
||||||
|
# cache on an unchanged tree and exits 0 having linted nothing, and
|
||||||
|
# BuildKit ignores a --no-cache-filter whose stage name matches
|
||||||
|
# nothing, restoring that no-op after a rename. So the run is not
|
||||||
|
# trusted: script/assert-step-ran requires the log to show the lint
|
||||||
|
# step executing and golangci-lint's own success line coming out of
|
||||||
|
# it. A cached or absent step fails that.
|
||||||
|
#
|
||||||
|
# 2. .dockerignore decides what reaches the container, and only what
|
||||||
|
# reaches it is linted, so excluding a Go file drops it from the lint
|
||||||
|
# with the linter still reporting `0 issues.` over what it was
|
||||||
|
# handed. So the context is not trusted either: the lint stage
|
||||||
|
# inventories the sources that reached it, and
|
||||||
|
# script/assert-context-complete compares that against the git index,
|
||||||
|
# which .dockerignore cannot touch. Never exclude Go sources,
|
||||||
|
# go.mod/go.sum or .golangci.yml — and now nothing silently does.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
dockerfile=Dockerfile.lint
|
||||||
|
|
||||||
|
# Must match the stage name in Dockerfile.lint.
|
||||||
|
stage=lint
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
tmp="$(mktemp -d "${TMPDIR:-/tmp}/$("$SCRIPT_DIR/projectname")-lint.XXXXXX")"
|
||||||
|
trap 'rm -rf "$tmp"' EXIT INT TERM
|
||||||
|
|
||||||
|
# No --target: whatever stage is last is the one BuildKit builds, and
|
||||||
|
# the assertion below is what decides whether the linter was in it.
|
||||||
|
# --progress=plain is load-bearing — the tty renderer discards all but
|
||||||
|
# the tail of a step's output. The exit status travels through a file
|
||||||
|
# because a pipeline's status is the last command's; `set +e` is what
|
||||||
|
# lets the recording line run at all, since errexit would otherwise
|
||||||
|
# abandon the subshell on a failing build. A missing file reads as
|
||||||
|
# failure.
|
||||||
|
(
|
||||||
|
set +e
|
||||||
|
docker build \
|
||||||
|
--progress=plain \
|
||||||
|
--no-cache-filter="$stage" \
|
||||||
|
--output=type=cacheonly \
|
||||||
|
-f "$dockerfile" . 2>&1
|
||||||
|
echo "$?" >"$tmp/status"
|
||||||
|
) | tee "$tmp/build.log"
|
||||||
|
|
||||||
|
status="$(cat "$tmp/status" 2>/dev/null || echo 1)"
|
||||||
|
[ "${status:-1}" -eq 0 ] || exit "${status:-1}"
|
||||||
|
|
||||||
|
script/assert-step-ran "$tmp/build.log" "$stage" \
|
||||||
|
'^RUN golangci-lint run' '^0 issues[.]$' 'the linter'
|
||||||
|
|
||||||
|
# That the linter ran says nothing about what it was given. The
|
||||||
|
# expectation comes from git and the evidence from the build, which
|
||||||
|
# is the only reason comparing them means anything.
|
||||||
|
script/repo-source-manifest >"$tmp/expected"
|
||||||
|
script/assert-context-complete "$tmp/build.log" "$stage" "$tmp/expected"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/precommit: run by the git pre-commit hook; fails the commit if
|
||||||
|
# checks fail. Our own extension to scripts-to-rule-them-all. Go repo
|
||||||
|
# extras: go mod tidy must not change go.mod/go.sum.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
go mod tidy
|
||||||
|
git diff --exit-code -- go.mod go.sum || {
|
||||||
|
echo "precommit: go mod tidy changed go.mod/go.sum;" \
|
||||||
|
"stage the changes and retry" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
"$SCRIPT_DIR/check"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+52
@@ -0,0 +1,52 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/prettier: run prettier over this repo with the repo's own
|
||||||
|
# .prettierrc. Helper, not an entrypoint: script/fmt and
|
||||||
|
# script/fmt-check call it.
|
||||||
|
#
|
||||||
|
# Prettier from PATH if it is there, otherwise a hash-pinned node image
|
||||||
|
# via docker, so a checkout with neither node nor a global prettier
|
||||||
|
# still formats identically. If neither is available the caller is told
|
||||||
|
# and the markdown pass is skipped rather than silently passing — a
|
||||||
|
# formatter that is not present cannot certify formatting.
|
||||||
|
#
|
||||||
|
# Exit status: prettier's own, 2 when it was skipped for lack of a
|
||||||
|
# runner. script/fmt-check treats 2 as a warning, not a failure, so
|
||||||
|
# that `make check` still works on a machine without docker.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
# node:24-alpine, 2026-08-22. Bumping the pin is a deliberate edit.
|
||||||
|
NODE_IMAGE="node:24-alpine@sha256:d32cdf619f63fe0471182d08996dd516c6275bb5fd31ae06e55a570bd9e1ad43"
|
||||||
|
|
||||||
|
# Pinned exactly: prettier's default formatting changes between minor
|
||||||
|
# releases, so an unpinned version turns fmt-check into a coin flip.
|
||||||
|
PRETTIER_VERSION="3.9.6"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
if command -v prettier >/dev/null 2>&1; then
|
||||||
|
prettier "$@"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
if command -v docker >/dev/null 2>&1; then
|
||||||
|
# --user keeps written files owned by the invoking user rather
|
||||||
|
# than root; the container only ever touches the bind mount.
|
||||||
|
docker run --rm \
|
||||||
|
--user "$(id -u):$(id -g)" \
|
||||||
|
-v "$ROOT:/work" \
|
||||||
|
-w /work \
|
||||||
|
"$NODE_IMAGE" \
|
||||||
|
npx --yes "prettier@$PRETTIER_VERSION" "$@"
|
||||||
|
return $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "script/prettier: neither prettier nor docker found;" >&2
|
||||||
|
echo "script/prettier: skipping markdown/CSS/JS formatting." >&2
|
||||||
|
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/projectname: output the name of this project. Our own
|
||||||
|
# extension to scripts-to-rule-them-all. Other scripts that need the
|
||||||
|
# name (e.g. script/docker) call this, so they can stay identical
|
||||||
|
# across all repos.
|
||||||
|
#
|
||||||
|
# Seeding a new project: script/rename rewrites the name below. Nothing
|
||||||
|
# else should hardcode it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
main() {
|
||||||
|
echo "simplexcalc"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+60
@@ -0,0 +1,60 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/repo-source-manifest: print every file this repository contains
|
||||||
|
# whose absence from a docker build context would go unnoticed — one
|
||||||
|
# repo-relative path per line, LC_ALL=C-sorted.
|
||||||
|
#
|
||||||
|
# That is the Go sources. A missing go.mod, go.sum or .golangci.yml
|
||||||
|
# fails the build loudly (the COPY errors, or golangci-lint refuses to
|
||||||
|
# start), so they cannot hide anything; they are listed anyway because
|
||||||
|
# it costs two lines and makes the manifest the linter's whole input
|
||||||
|
# rather than most of it.
|
||||||
|
#
|
||||||
|
# The list comes from the git index, never from a walk of the working
|
||||||
|
# tree, and that is the point: script/assert-context-complete compares
|
||||||
|
# it against the inventory the build itself emitted, and a check that
|
||||||
|
# reads its expectation from the same place it reads its evidence proves
|
||||||
|
# nothing. .dockerignore governs what docker sends; it cannot touch what
|
||||||
|
# git tracks.
|
||||||
|
#
|
||||||
|
# Tracked files only, and only those present in the worktree — a file
|
||||||
|
# staged for deletion is not something the build context is missing.
|
||||||
|
# Untracked files are not expected either, so local scratch work in the
|
||||||
|
# tree is not a failure.
|
||||||
|
#
|
||||||
|
# A path containing a newline or a quote is quoted by git and will not
|
||||||
|
# match the plain path the build emits, so it fails loudly rather than
|
||||||
|
# passing silently. No such path exists here, and none should.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
die() {
|
||||||
|
echo "script/repo-source-manifest: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
git rev-parse --is-inside-work-tree >/dev/null 2>&1 ||
|
||||||
|
die "not a git work tree, so there is nothing to compare a build context against"
|
||||||
|
|
||||||
|
# Captured before the loop so that a git failure is the script's
|
||||||
|
# exit status: in a pipeline only the last command's status counts.
|
||||||
|
tracked="$(git ls-files -- '*.go' go.mod go.sum .golangci.yml .golangci.yaml)"
|
||||||
|
|
||||||
|
manifest="$(
|
||||||
|
printf '%s\n' "$tracked" | while IFS= read -r f; do
|
||||||
|
if [ -n "$f" ] && [ -f "$f" ]; then
|
||||||
|
printf '%s\n' "$f"
|
||||||
|
fi
|
||||||
|
done | LC_ALL=C sort
|
||||||
|
)"
|
||||||
|
|
||||||
|
[ -n "$manifest" ] ||
|
||||||
|
die "the git index lists no Go sources, so any build context would satisfy the check"
|
||||||
|
|
||||||
|
printf '%s\n' "$manifest"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+14
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/setup: set up the repo for development after a fresh clone:
|
||||||
|
# installs dependencies (script/bootstrap) and the git pre-commit hook.
|
||||||
|
# Add any repo-specific initialization (db init, .env template) here.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
"$SCRIPT_DIR/bootstrap"
|
||||||
|
"$SCRIPT_DIR/install-precommit"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Executable
+21
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/test: run the test suite. Quiet on success; on failure, rerun
|
||||||
|
# with -v for full diagnostic output (and still fail — the first run
|
||||||
|
# already proved the tests are broken).
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
# -count=1 disables the test result cache. Without it a repeat run on
|
||||||
|
# an unchanged tree reports every package `ok ... (cached)` and exits
|
||||||
|
# 0 having executed nothing — a gate that cannot fail.
|
||||||
|
go test -count=1 -timeout 90s -race -cover ./... || {
|
||||||
|
echo "--- Rerunning with -v for details ---"
|
||||||
|
go test -count=1 -timeout 90s -race -v ./...
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/* No external fonts, no CDN: the Content-Security-Policy this service
|
||||||
|
sends is default-src 'self', and everything here has to live inside
|
||||||
|
it. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--fg: #1a1a1a;
|
||||||
|
--bg: #fdfdfc;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--rule: #dcdcd8;
|
||||||
|
--accent: #2b5f8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--fg: #e6e6e3;
|
||||||
|
--bg: #16181a;
|
||||||
|
--muted: #9a9a95;
|
||||||
|
--rule: #2e3236;
|
||||||
|
--accent: #7fb2dd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
font:
|
||||||
|
16px/1.55 ui-monospace,
|
||||||
|
"SFMono-Regular",
|
||||||
|
Menlo,
|
||||||
|
Consolas,
|
||||||
|
monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 0.9rem 1.25rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
nav .brand {
|
||||||
|
font-weight: 700;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.5rem 1.25rem 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-top: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: end;
|
||||||
|
margin: 1rem 0 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
button {
|
||||||
|
font: inherit;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
cursor: pointer;
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
border-bottom: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
justify-content: space-between;
|
||||||
|
max-width: 52rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.25rem 2rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// The Content-Security-Policy this service sends forbids inline script
|
||||||
|
// and third-party origins, so anything the pages need has to be a
|
||||||
|
// same-origin file like this one. It is deliberately empty of
|
||||||
|
// behaviour: it exists so the embedded-asset path is exercised by
|
||||||
|
// something other than the stylesheet.
|
||||||
|
//
|
||||||
|
// Delete it, or fill it in, when seeding a real project.
|
||||||
|
|
||||||
|
("use strict");
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Package static carries the static assets into the binary, so that a
|
||||||
|
// deployment is one file and an asset can never be a version behind the
|
||||||
|
// code that references it.
|
||||||
|
package static
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// FS holds the served assets. internal/server mounts it read-only at
|
||||||
|
// /static/ and refuses directory listings.
|
||||||
|
//
|
||||||
|
//go:embed css/*.css js/*.js
|
||||||
|
var FS embed.FS
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{{define "base"}}<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>{{template "title" .}} — {{.AppName}}</title>
|
||||||
|
<link rel="stylesheet" href="/static/css/style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
{{template "navbar" .}}
|
||||||
|
<main>{{template "content" .}}</main>
|
||||||
|
{{template "footer" .}}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "title"}}{{.Status}}{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.Status}}</h1>
|
||||||
|
<p>{{.Message}}</p>
|
||||||
|
<p><a href="/">back</a></p>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
{{define "title"}}widgets{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<h1>{{.AppName}}</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
This page is rendered from an embedded template. The stylesheet, the
|
||||||
|
partials in the navigation and footer, and the schema behind the
|
||||||
|
table below all ship inside the binary.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>widgets ({{.WidgetCount}})</h2>
|
||||||
|
|
||||||
|
<form method="POST" action="/widgets">
|
||||||
|
{{.CSRFField}}
|
||||||
|
<label>
|
||||||
|
name
|
||||||
|
<input type="text" name="name" required maxlength="200" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
size
|
||||||
|
<input type="text" name="size" placeholder="4KiB" />
|
||||||
|
</label>
|
||||||
|
<button type="submit">create</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{if .Widgets}}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>name</th>
|
||||||
|
<th>size</th>
|
||||||
|
<th>created</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{range .Widgets}}
|
||||||
|
<tr>
|
||||||
|
<td>{{.Name}}</td>
|
||||||
|
<td>{{bytes .SizeBytes}}</td>
|
||||||
|
<td>{{since .CreatedAt}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty">No widgets yet.</p>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{{define "footer"}}
|
||||||
|
<footer>
|
||||||
|
<span>{{.AppName}} {{.Version}} ({{.Buildarch}})</span>
|
||||||
|
<span>up {{.Uptime}}</span>
|
||||||
|
</footer>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{{define "navbar"}}
|
||||||
|
<nav>
|
||||||
|
<a class="brand" href="/">{{.AppName}}</a>
|
||||||
|
<a href="/">home</a>
|
||||||
|
<a href="/.well-known/healthcheck.json">health</a>
|
||||||
|
</nav>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Package templates carries the HTML templates and partials into the
|
||||||
|
// binary. It holds no logic: internal/render owns parsing and
|
||||||
|
// execution, and this exists only so the embed directive sits next to
|
||||||
|
// the files it embeds.
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
// FS holds every page template and partial.
|
||||||
|
//
|
||||||
|
// A file that is not matched here does not exist at runtime, and the
|
||||||
|
// failure is a 500 at request time rather than a build error — so the
|
||||||
|
// patterns are deliberately whole-directory, and internal/render parses
|
||||||
|
// all of them at startup so that a broken template fails the process
|
||||||
|
// instead of one request.
|
||||||
|
//
|
||||||
|
//go:embed *.html partials/*.html
|
||||||
|
var FS embed.FS
|
||||||
Reference in New Issue
Block a user