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:
clawbot
2026-09-26 21:38:57 +00:00
parent e336f46e34
commit f8ce8cef83
79 changed files with 7131 additions and 0 deletions
+26
View File
@@ -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/
+15
View File
@@ -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
+18
View File
@@ -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
View File
@@ -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/
+41
View File
@@ -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
+9
View File
@@ -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/
+9
View File
@@ -0,0 +1,9 @@
{
"useTabs": false,
"tabWidth": 4,
"proseWrap": "always",
"printWidth": 72,
"semi": true,
"singleQuote": false,
"trailingComma": "all"
}
+178
View File
@@ -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
View File
@@ -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"]
+35
View File
@@ -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 ./...
+54
View File
@@ -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
+37
View File
@@ -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)
}
}
+48
View File
@@ -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
},
}
}
+80
View File
@@ -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
}
+446
View File
@@ -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`
+53
View File
@@ -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
+51
View File
@@ -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
)
+152
View File
@@ -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=
+72
View File
@@ -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)
}
+390
View File
@@ -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
}
+257
View File
@@ -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)
}
}
+17
View File
@@ -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
+29
View File
@@ -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
}
+211
View File
@@ -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
}
+219
View File
@@ -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)
}
}
+199
View File
@@ -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
}
+106
View File
@@ -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
}
+15
View File
@@ -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);
+15
View File
@@ -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);
+43
View File
@@ -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
}
+13
View File
@@ -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")
)
+123
View File
@@ -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)
}
}
+77
View File
@@ -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")
}
}
+114
View File
@@ -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
}
+92
View File
@@ -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,
)
}
+46
View File
@@ -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"
+116
View File
@@ -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)
}
+163
View File
@@ -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
}
+314
View File
@@ -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")
}
}
+59
View File
@@ -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)
})
}
}
+63
View File
@@ -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
}
+67
View File
@@ -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)
})
}
}
+13
View File
@@ -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")
)
+240
View File
@@ -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
}
+197
View File
@@ -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)
}
}
+55
View File
@@ -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),
}
}
+87
View File
@@ -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)
},
))
}
+180
View File
@@ -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
}
+560
View File
@@ -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)
}
}
+48
View File
@@ -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
}
+162
View File
@@ -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)
})
}
+152
View File
@@ -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"))
}
+112
View File
@@ -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)
}
}
+43
View File
@@ -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
}
+166
View File
@@ -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"
+125
View File
@@ -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"
+84
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"
+25
View File
@@ -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 "$@"
+16
View File
@@ -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
View File
@@ -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 "$@"
+21
View File
@@ -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 "$@"
+52
View File
@@ -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 "$@"
+15
View File
@@ -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 "$@"
+60
View File
@@ -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
View File
@@ -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
View File
@@ -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 "$@"
+135
View File
@@ -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);
}
+9
View File
@@ -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");
+12
View File
@@ -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
+15
View File
@@ -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}}
+7
View File
@@ -0,0 +1,7 @@
{{define "title"}}{{.Status}}{{end}}
{{define "content"}}
<h1>{{.Status}}</h1>
<p>{{.Message}}</p>
<p><a href="/">back</a></p>
{{end}}
+49
View File
@@ -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}}
+6
View File
@@ -0,0 +1,6 @@
{{define "footer"}}
<footer>
<span>{{.AppName}} {{.Version}} ({{.Buildarch}})</span>
<span>up {{.Uptime}}</span>
</footer>
{{end}}
+7
View File
@@ -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}}
+18
View File
@@ -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