19 Commits
Author SHA1 Message Date
clawbot db5eb507c1 server.go starts listenAndServe in an anonymous function (closes #162)
check / check (push) Waiting to run
The server's OnStart hook started the HTTP listener with
`go s.listenAndServe()`; the Go style guide asks for a function called
with `go` to be wrapped in an anonymous function, so it is now
`go func() { s.listenAndServe() }()`. No other go statement in the Go
code called a function directly. Nothing the server does changed.

Model: opus-5-5
2026-10-19 08:24:49 +02:00
clawbot 6e4c44b3d6 Match .gitignore, .editorconfig and .golangci.yml to sneak/prompts (closes #174)
check / check (push) Canceled after 0s
REPO_POLICIES.md asks for the standard .gitignore and .editorconfig
followed by the repository's own entries, and for the canonical
.golangci.yml. The shared parts and .golangci.yml now match the
sneak/prompts copies at 065357920a0a09bd736bf33d4cb5ce935fdc2328 byte
for byte. .editorconfig has no entries of its own left, since the
standard file now carries the [*.go] section. .golangci.yml disables
canonicalheader, as the canonical copy does.

Model: opus-5-5
2026-10-19 08:22:05 +02:00
clawbot 91eb3d6771 package.json drops its version field and is private (closes #158)
check / check (push) Canceled after 0s
package.json said version 1.0.0, a release that has not been made.
Nothing reads the field, and the package is never published; the git
tag is the one version, which the image and make build take from
git describe --tags. The field is gone, so there is nothing to keep in
step with tags, and the package is marked "private": true.

Model: opus-5-5
2026-10-19 08:16:29 +02:00
clawbot edcf7f8c3c TODO.md Status says where release candidates are tagged (closes #157)
check / check (push) Canceled after 0s
Status said there were no git tags, but one is tagged on `next`. It now
says release candidates are tagged on `next`, names the first one, and
gives the command that lists them, so it stays true as more are tagged.
The other Status sentences match the tree and are unchanged.

Model: opus-5-5
2026-10-19 08:13:00 +02:00
clawbot 5b01b50bc7 netwatch-server refuses arguments it does not understand (closes #179)
check / check (push) Canceled after 0s
cli.Main starts the server only when given no arguments. check-cidr
CIDR and prepare-data-dir DATA_DIR run as before. Any other command
line, such as a subcommand with no value or two values, or a misspelt
subcommand, gets a one-line usage message naming both forms on stderr
and exit code 2. It used to start the server, which then held the
port.

Model: opus-5-5
2026-10-19 08:09:13 +02:00
clawbot 7a3c706fe6 main() is one call into internal/cli (closes #160)
check / check (push) Canceled after 0s
cmd/netwatch-server/main.go parsed the command line, ran the check-cidr
and prepare-data-dir subcommands and built the fx application itself;
the Go style guide puts all of that in internal/ or pkg/. The new
package internal/cli does it, with the same arguments, messages and
exit codes. main() is now os.Exit(cli.Main(os.Stderr, Appname, Version,
os.Args[1:], cli.RunServer)): Main takes the writer for errors and the
function that runs the server, so its tests give it a buffer and a
function that fails them. Appname and Version stay in main, where the
build sets Version. The function behind prepare-data-dir also takes the
volume and the user's name, so its own tests give it a temporary
directory and the user they run as.

Model: opus-5-5
2026-10-19 07:53:09 +02:00
clawbot 7a2b6b0494 script/build stamps lightweight tags too (closes #156)
check / check (push) Canceled after 0s
When VERSION is unset or empty, script/build now takes the version from
`git describe --tags --always --dirty`, the form script/docker and
script/cibuild use. Without --tags it ignored lightweight tags, so a
binary from `make build` could report a commit where the image built
from the same tree reported the tag. README.md Entrypoints names the
same form.

Model: opus-5-5
2026-10-19 07:45:17 +02:00
clawbot a42a220bb7 .dockerignore keeps root test binaries and coverage out (closes #155)
check / check (push) Canceled after 0s
A Go test binary or coverage file left at the repository root, which
.gitignore already ignores, went into the build context of every docker
build in script/ and was copied into the lint, test and build stages.
The repository's own section of .dockerignore now lists /*.test and
/*.out, root-anchored like its other entries.

Checked with a probe image that copies the whole context: x.test and
x.out planted at the root are no longer in it, and nothing else changed.

Model: opus-5-5
2026-10-19 07:42:43 +02:00
clawbot 65c0b6bcbe CI checkout fetches all history and tags (closes #153)
check / check (push) Canceled after 0s
The checkout step cloned one commit and fetched no tags, so git
describe in script/cibuild gave the image built from a tagged commit
the short commit as its version. .gitea/workflows/check.yml is now the
sneak/prompts copy at 0653579, whose checkout step sets fetch-depth: 0.
That copy also sets persist-credentials: false on the checkout, cancels
the older run on the same branch when a new push arrives, and stops a
run after 20 minutes. The TODO.md status line says the workflow cancels
the older run.

Model: opus-5-5
2026-10-19 07:32:35 +02:00
clawbot 8879aeb68e Bootstrap keeps yarn at its pinned version and node at its pinned major (closes #154)
check / check (push) Waiting to run
script/bootstrap kept any yarn on PATH, so YARN_VERSION applied
only where none was installed. It now keeps the yarn on PATH only when
yarn --version is exactly YARN_VERSION, and otherwise installs that
version. The node on PATH is used only when it has the pinned major
version, as REPO_POLICIES.md asks, and is at least NODE_MIN_VERSION.
After installing either, bootstrap looks the tool up through PATH
again, stops, naming the path, if that one fails the same check, and
prints the version it uses, as for Go. Every other host script that
runs yarn now goes through the new script/yarn, which uses the first
node on PATH, ~/.local/bin first, when it passes the same test, and
otherwise the pinned node in nvm.

Model: opus-5-5
2026-10-19 07:20:42 +02:00
clawbot 0ea169ec46 Entrypoint steps are functions called from main (closes #163)
check / check (push) Waiting to run
bin/entrypoint.sh was top-level code with one helper; the shell section
of the code style guide asks for every step in a function and a main
called at the end. The PORT and TRUSTED_PROXIES checks, the data
directory, starting each process, the wait loop, stopping both and the
exit are now functions, which main calls in order, setting the stop
trap before either process starts. It stays POSIX sh with no set -e.
Messages and exit codes are unchanged; the 8081 message is passed to
echo as two arguments so its line fits in 80 columns.

Model: opus-5-5
2026-10-19 07:17:32 +02:00
clawbot 9285591563 Health check answers 503 while report files cannot be written (closes #142)
check / check (push) Waiting to run
The health check answered 200 "status":"ok" whatever happened, so a
container whose report files could not be written stayed healthy while
every report was refused with 500. The report buffer now says whether
its last write of a report file failed. While it has, the health
check's status is "error" and the handler answers 503 with the same
fields, which fails the image's HEALTHCHECK. At start no write has
failed, and the first write that succeeds brings back 200. A test
removes DATA_DIR to make a write fail and checks the 503, then the 200
once a write succeeds again.

Model: opus-5-5
2026-10-19 06:55:51 +02:00
clawbot 9677c73e0a Report file writes run one at a time (closes #150)
check / check (push) Waiting to run
Two writes that failed at the same time put their reports back in the
wrong order when the older one failed first. A mutex is now held
across each write, from taking the waiting reports out of the buffer
to putting them back if the write fails, so the next write takes them
in order. The write Append starts at the 10 MiB threshold waits for
one under way, and Append starts no other while it waits. Files
written since the start are now deleted to make room in the order they
were written. A test that needed two writes at once is removed; new
tests cover the one waiting write and the order files are deleted in.

Model: opus-5-5
2026-10-19 06:02:11 +02:00
clawbot f5aa81f024 DEBUG turns debug logging on for any non-empty value (closes #159)
check / check (push) Waiting to run
DEBUG was read with strconv.ParseBool, so a value such as "yes" stopped
the start. It now follows the code style guide: unset or empty is off,
and any other value, "false" and "0" included, turns debug logging on.
No value of DEBUG stops the start. The test that "yes" fails is replaced
by one covering unset, empty, "false", "0" and "yes". README.md no
longer gives false as the default, and says which values turn it on.

Model: opus-5-5
2026-10-19 05:48:20 +02:00
clawbot 4e3943f02f Check builds keep Go's modules and caches between runs (refs #118)
check / check (push) Waiting to run
On sneak's rulings in sneak/prompts#113 and
sneak/prompts#130. REPO_POLICIES.md and
script/test, script/lint, script/docker and script/cibuild are the
sneak/prompts copies at 0653579: the scripts pass a new CACHEBUST value
instead of --no-cache, and the lint and test builds write no image. In
each Dockerfile stage the system packages and the Go module download
come above ARG CACHEBUST; every step that lints, tests or compiles, and
the node stages' yarn install, comes after it. Go's build cache and
golangci-lint's are cache mounts, and both go test runs carry -count=1.
README.md and TODO.md say the same.

Model: opus-5-5
2026-10-19 04:26:25 +02:00
clawbot 648c225919 Discarded-check unit tests check nothing was recorded (closes #151)
check / check (push) Waiting to run
The three unit tests of a check whose result is discarded (its round
given up, the user paused, or the first round) checked only that the
row showed nothing. Each now also checks that the host's history is
still empty, its status still pending, and nothing was written to the
console, so a change that recorded the result and skipped only the row
fails them. The console helper moves above its first use.

Model: opus-5-5
2026-10-19 03:44:13 +02:00
clawbot be6dc93eaa Unusable DATA_DIR error names DATA_DIR; report file number is atomic.Int64 (closes #148, closes #161)
check / check (push) Waiting to run
When the backend runs on its own, a DATA_DIR that could not be created
or read stopped the start with an error that named the path but not
DATA_DIR, and one where no file could be created let it start, to fail
at the first write of a report file. The start now creates a file in
DATA_DIR and removes it, and all three cases fail with
DATA_DIR "<value>": <reason>, the form config.New uses for the other
settings. Tests start the buffer with DATA_DIR below a regular file and
with DATA_DIR set to /proc.

The report file sequence counter is atomic.Int64 instead of
atomic.Uint64, as the Go style guide asks for int types. File names are
unchanged.

Model: opus-5-5
2026-10-19 03:15:35 +02:00
clawbot 6df743f7fa Page is sent with Cache-Control: no-cache (closes #141)
check / check (push) Waiting to run
nginx sent index.html with no Cache-Control, so a browser could keep
it after a deploy and ask for hashed assets the new build no longer
has, and show an empty page. location / now adds Cache-Control:
no-cache, which covers its try_files fallback to /index.html, and
includes security-headers.conf again, since its own add_header drops
the server's. /assets/ and the paths passed to the backend are
unchanged.

script/frontend-viewport-test fails unless / and a path that falls
back to the page carry Cache-Control: no-cache and
Content-Security-Policy, which comes from the same file as the other
security headers.

Model: opus-5-5
2026-10-19 03:10:39 +02:00
clawbot 984047e6ca README states what refused reports, one sender and nginx's 413 do (closes #145, closes #146, closes #147)
check / check (push) Waiting to run
The README said the page keeps refused samples and sends them later.
Those in the 100-round history are sent only while it holds them: at
the 1-second interval one refused report loses the oldest 20 seconds of
each host's samples, and a pause longer than the history loses every
sample between the last delivered report and the pause. Samples an
interval change cleared are kept apart, the last 100 per host, until
delivered. Report limits now says one address at the defaults can fill
the 1 GiB cap in about 17 minutes. In the image, nginx answers a body
over 1 MiB itself, with 413 and an HTML page; the backend's own 413
sentences now apply to it run on its own.

Model: opus-5-5
2026-10-19 02:55:29 +02:00
39 changed files with 1813 additions and 566 deletions
+4 -1
View File
@@ -73,9 +73,12 @@
# This repository's own entries, after the shared content above: the # This repository's own entries, after the shared content above: the
# frontend build, the viewport test's output, the binary `make build` # frontend build, the viewport test's output, the binary `make build`
# writes and the reports `make run` stores, and log files at the root. # writes and the reports `make run` stores, and Go test binaries,
# coverage output and log files at the root.
/dist /dist
/tmp /tmp
/netwatch-server /netwatch-server
/data /data
/*.test
/*.out
/*.log /*.log
+3 -2
View File
@@ -11,7 +11,8 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
# This repository's own entries, after the shared content above.
[*.go] [*.go]
indent_style = tab indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
+11
View File
@@ -1,9 +1,20 @@
name: check name: check
on: [push] on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild - run: script/cibuild
+4 -2
View File
@@ -27,7 +27,7 @@ node_modules/
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are # convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds # re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`. # its own negation at the end of this file, for example `!.env.example`.
*.[eE][nN][vV] *.[eE][nN][vV]
.[eE][nN][vV].* .[eE][nN][vV].*
.[eE][nN][vV][rR][cC] .[eE][nN][vV][rR][cC]
@@ -46,7 +46,9 @@ node_modules/
[iI][dD]_[eE][dD]25519 [iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK] [iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, after the shared content above. # This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# Build output # Build output
dist/ dist/
+2
View File
@@ -25,6 +25,8 @@ linters:
# silenced by disabling that name, not by enabling the successor. # silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2 - gomodguard # Deprecated, replaced by gomodguard_v2
# Misses findings at random in v2.14.0; back once a pinned release fixes it
- canonicalheader
settings: settings:
lll: lll:
line-length: 88 line-length: 88
+39 -17
View File
@@ -10,11 +10,22 @@
# phase covers the frontend as well, through a copy from a node stage. # phase covers the frontend as well, through a copy from a node stage.
# Inside them each tool is invoked directly, never through make or # Inside them each tool is invoked directly, never through make or
# script/, whose lint and test are themselves docker builds. # script/, whose lint and test are themselves docker builds.
#
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it, as does yarn install in the node stages. script/
# passes a new value on every build, which reruns every RUN step after
# it. Docker keeps the layers above it until the base image, their own
# lines or go.mod and go.sum change. The steps that compile mount Go's
# build cache, and golangci-lint run also mounts golangci-lint's; both
# survive from build to build.
# Frontend lint stage: eslint with the rules in eslint.config.js. The # Frontend lint stage: eslint with the rules in eslint.config.js. The
# lint phase below runs it. # lint phase below runs it.
# node:22-alpine as of 2026-02-22 # node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint
ARG CACHEBUST
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
@@ -29,8 +40,11 @@ FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a924
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Nothing is wanted from frontend-lint; the copy is what makes this # Nothing is wanted from frontend-lint; the copy is what makes this
# phase run it. # phase run it.
COPY --from=frontend-lint /app/yarn.lock /dev/null COPY --from=frontend-lint /app/yarn.lock /dev/null
@@ -43,6 +57,7 @@ COPY --from=frontend-lint /app/yarn.lock /dev/null
# node would take a reporter option for one more file. # node would take a reporter option for one more file.
# node:22-alpine as of 2026-02-22 # node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test
ARG CACHEBUST
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
@@ -58,19 +73,19 @@ RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \
# ships and the alpine one does not. -timeout 90s is a backstop above # ships and the alpine one does not. -timeout 90s is a backstop above
# the 60-second cap on the suite. The rerun with -v only shows details: # the 60-second cap on the suite. The rerun with -v only shows details:
# the step fails however it ends, because the first run already failed. # the step fails however it ends, because the first run already failed.
# Go's module and build caches are in memory (tmpfs) for the go test # -count=1 because the mounted cache also holds test results, which go
# step alone, so the image make test tags does not carry them and the # test would otherwise replay.
# build spends no time writing them into it. They start empty on every
# build, so no stored test result can stand in for a run.
# golang:1.25.7-trixie, 2026-10-07 # golang:1.25.7-trixie, 2026-10-07
FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN --mount=type=tmpfs,target=/go/pkg/mod \ RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=tmpfs,target=/root/.cache/go-build \ go test -count=1 -timeout 90s -race -cover ./... || \
go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; } go test -count=1 -timeout 90s -race -v ./...; exit 1; }
# Nothing is wanted from the frontend-test stage; the copy is what makes # Nothing is wanted from the frontend-test stage; the copy is what makes
# this phase run its tests. # this phase run its tests.
COPY --from=frontend-test /app/yarn.lock /dev/null COPY --from=frontend-test /app/yarn.lock /dev/null
@@ -83,23 +98,28 @@ COPY --from=frontend-test /app/yarn.lock /dev/null
FROM frontend-test AS frontend-build FROM frontend-test AS frontend-build
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
# vite.config.js reads the commit for the page's footer with git. # vite.config.js reads the commit for the page's footer with git. git is
# installed again on every image build, although it comes above
# ARG CACHEBUST, because this stage starts from frontend-test, whose
# steps run again on every build.
RUN apk add --no-cache git RUN apk add --no-cache git
ARG CACHEBUST
RUN yarn build RUN yarn build
# Backend build stage. Nothing is wanted from the two phases; the copies # Backend build stage. Nothing is wanted from the two phases; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so nothing after them can
# unless lint and test passed. # run unless lint and test passed.
# golang:1.25-alpine (2026-02-27) # golang:1.25-alpine (2026-02-27)
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . . COPY . .
# The same go build as in script/build, which builds the binary on the # The same go build as in script/build, which builds the binary on the
@@ -112,7 +132,8 @@ COPY . .
# still empty, dev or unknown fails the build: git is missing or could # still empty, dev or unknown fails the build: git is missing or could
# not read the checkout. # not read the checkout.
ARG VERSION ARG VERSION
RUN version="${VERSION:-$(git describe --tags --always)}"; \ RUN --mount=type=cache,target=/root/.cache/go-build \
version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \ if [ -e .git ]; then \
case "$version" in ""|dev|unknown) \ case "$version" in ""|dev|unknown) \
echo "version is '$version' although .git is present" >&2; \ echo "version is '$version' although .git is present" >&2; \
@@ -127,6 +148,7 @@ RUN version="${VERSION:-$(git describe --tags --always)}"; \
# and the stages it copies from, the two phases included. # and the stages it copies from, the two phases included.
# nginx:stable-alpine as of 2026-02-22 # nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
ARG CACHEBUST
# netwatch-server runs as this user, which owns the report directory. # netwatch-server runs as this user, which owns the report directory.
# nginx keeps the image's own arrangement: its main process runs as # nginx keeps the image's own arrangement: its main process runs as
+114 -36
View File
@@ -41,11 +41,13 @@ The scripts cover both the frontend and the Go backend, so `make check` fails if
either one is broken. We provide: either one is broken. We provide:
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless - `script/bootstrap` — install all dependencies (the pinned node via nvm unless
one new enough for the frontend's dependencies is installed, yarn via the `node` on `PATH` has the pinned major version and is new enough for the
corepack, `yarn install --frozen-lockfile`, Go `GO_VERSION` unless the `go` on frontend's dependencies, yarn `YARN_VERSION` via corepack, or npm where there
`PATH` is exactly that version, and the Go modules), linking what it installs is no corepack, unless the `yarn` on `PATH` is exactly that version,
itself into `~/.local/bin`, which has to be on `PATH`. It installs no Go `yarn install --frozen-lockfile`, Go `GO_VERSION` unless the `go` on `PATH` is
linter and not Docker: `make test` and `make lint` run in Docker exactly that version, and the Go modules), linking what it installs itself
into `~/.local/bin`, which has to be on `PATH`. It installs no Go linter and
not Docker: `make test` and `make lint` run in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/dev` — run the Vite dev server, which proxies `/api` to a locally - `script/dev` — run the Vite dev server, which proxies `/api` to a locally
@@ -53,16 +55,23 @@ either one is broken. We provide:
- `script/build` — build the frontend for production into `dist/`, then the - `script/build` — build the frontend for production into `dist/`, then the
static `netwatch-server` binary into the repo root with its version stamped static `netwatch-server` binary into the repo root with its version stamped
in: `VERSION` from the environment, or when that is unset or empty, in: `VERSION` from the environment, or when that is unset or empty,
`git describe` inside a git checkout, then `dev` `git describe --tags --always --dirty` inside a git checkout, as
`script/docker` does, then `dev`
- `script/run` — build with `script/build`, then run `netwatch-server` with its - `script/run` — build with `script/build`, then run `netwatch-server` with its
defaults (see [Settings](#settings)) defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes - `script/clean` — remove what `script/build` writes
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — build the `test` phase of `Dockerfile` without the cache: the - `script/yarn` — run yarn with the given arguments under the node
`script/bootstrap` would use: with `~/.local/bin` first on `PATH`, the first
`node` on that `PATH` when it has the pinned major version and is new enough
for the frontend's dependencies, and otherwise the pinned node in nvm. Every
script that runs yarn on the host runs it through this one, except
`script/bootstrap`, which picks its own node and yarn
- `script/test` — build the `test` phase of `Dockerfile`, writing no image: the
frontend's unit tests in its `frontend-test` stage, and the backend's Go tests frontend's unit tests in its `frontend-test` stage, and the backend's Go tests
with the race detector and coverage. The frontend's production build is left with the race detector and coverage. The frontend's production build is left
to the image build, in the `frontend-build` stage to the image build, in the `frontend-build` stage
- `script/lint` — build the `lint` phase of `Dockerfile` without the cache: - `script/lint` — build the `lint` phase of `Dockerfile`, writing no image:
eslint in its `frontend-lint` stage, and golangci-lint over the Go packages eslint in its `frontend-lint` stage, and golangci-lint over the Go packages
- `script/fmt` — format all files (writes): prettier over the JavaScript, CSS, - `script/fmt` — format all files (writes): prettier over the JavaScript, CSS,
HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the
@@ -90,13 +99,25 @@ either one is broken. We provide:
starts, it checks that the page, and a path that falls back to it, are served starts, it checks that the page, and a path that falls back to it, are served
with `Cache-Control: no-cache` and `Content-Security-Policy`. Not part of with `Cache-Control: no-cache` and `Content-Security-Policy`. Not part of
`script/check`: it takes minutes. `script/check`: it takes minutes.
- `script/docker` — build the image from `Dockerfile` without the build cache, - `script/docker` — build the image from `Dockerfile`, tagged `netwatch` via
tagged `netwatch` via `script/projectname` `script/projectname`
- `script/cibuild` — CI entrypoint: runs `script/bootstrap` and `script/check`, - `script/cibuild` — CI entrypoint: runs `script/bootstrap` and `script/check`,
then builds the image as `script/docker` does, without the build cache then builds the image as `script/docker` does
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook
`script/test`, `script/lint`, `script/docker` and `script/cibuild` each pass a
new `CACHEBUST` value, so every step in `Dockerfile` that lints, tests or
compiles runs again even when nothing has changed. Docker keeps the system
packages and the Go modules from earlier builds until the base image, the step
that installs them, or `go.mod` and `go.sum` change. The `frontend-build` stage
is the exception: it installs git again on every image build, because it starts
from the `frontend-test` stage, whose steps run again on every build. Go's build
cache and golangci-lint's are kept as well, and each reuses a result only when
everything it was made from is unchanged. Both `go test` runs carry `-count=1`,
so no test result is reused. The yarn packages are installed again on every
build.
## Responsive layout ## Responsive layout
The narrow-viewport layout lives in the `max-width: 768px` media block in The narrow-viewport layout lives in the `max-width: 768px` media block in
@@ -155,8 +176,26 @@ report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Changing the interval clears delivered report, and while paused nothing is sent. Changing the interval clears
each host's history on the page; the samples in it not yet reported still go in each host's history on the page; the samples in it not yet reported still go in
the next report. Delivery failure is quiet — one debug-log line per outage, the next report. Delivery failure is quiet — one debug-log line per outage,
retried at the next interval, never blocking probing. The report-building step never blocking probing. The report-building step is a pure function of host
is a pure function of host state. state.
A sample in the history that no report has delivered is sent only as long as the
history holds it, so after a refused or failed report the next report carries
the samples the history still has. Samples an interval change cleared from the
history are kept apart, the last 100 per host, until they are delivered. The
history keeps each host's last 100 rounds: 100 seconds at the 1-second interval,
200 seconds at 2 seconds, 5 minutes at the default of 3 seconds and 100 minutes
at 60 seconds. At 1 second, one refused or failed report loses the oldest 20
seconds of each host's samples, as the next delivered report comes 2 minutes
after the last. At 2 seconds and longer one loses nothing; samples are lost from
the third refused report in a row at 2 seconds, from the fifth at 3 seconds, and
later still at the longer intervals.
No report is sent while the page is paused, but each paused round still adds an
entry to the history, so each report due during a pause counts as a refused one.
A pause longer than the history spans loses the samples in the history recorded
between the last delivered report and the pause: up to a minute's worth for each
host if the report before the pause was delivered.
### Backend ### Backend
@@ -169,7 +208,9 @@ stops. Its routes:
address may send a limited number a minute, and the report files are capped in address may send a limited number a minute, and the report files are capped in
size, the oldest deleted first size, the oldest deleted first
- `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the - `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the
server's version and its uptime server's version and its uptime; while reports cannot be stored, as the last
write of a report file failed, it answers 503 with `"status":"error"` and the
same fields (see [Report storage](#report-storage))
- `GET /metrics` — Prometheus metrics behind basic auth, only when - `GET /metrics` — Prometheus metrics behind basic auth, only when
`METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may `METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may
make a limited number of requests to it a minute make a limited number of requests to it a minute
@@ -177,6 +218,10 @@ stops. Its routes:
It is an `fx`-wired Go application: `cmd/netwatch-server/` starts it, and its It is an `fx`-wired Go application: `cmd/netwatch-server/` starts it, and its
packages are in `internal/`: packages are in `internal/`:
- **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and
`prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it
starts the server, and, given no arguments, the server, with the `fx` wiring.
It refuses any other arguments with a usage message and exit code 2.
- **`config`**: Loads configuration from environment variables and config files - **`config`**: Loads configuration from environment variables and config files
via Viper. via Viper.
- **`handlers`**: HTTP request handlers for the API (health check, report - **`handlers`**: HTTP request handlers for the API (health check, report
@@ -201,7 +246,7 @@ image, `make run` builds it and runs it on port 8080.
| `PORT` | `8080` | HTTP listen port | | `PORT` | `8080` | HTTP listen port |
| `DATA_DIR` | `./data/reports` | Directory for compressed reports | | `DATA_DIR` | `./data/reports` | Directory for compressed reports |
| `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; see [Report limits](#report-limits) | | `DATA_DIR_MAX_BYTES` | `1073741824` (1 GiB) | Most bytes of report files kept in `DATA_DIR`, oldest deleted first; see [Report limits](#report-limits) |
| `DEBUG` | `false` | Enable debug logging | | `DEBUG` | empty | Debug logging; any non-empty value, `false` and `0` included, turns it on |
| `TRUSTED_PROXIES` | RFC 1918 ranges | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution | | `TRUSTED_PROXIES` | RFC 1918 ranges | Comma-separated CIDRs whose `X-Forwarded-For` / `X-Real-IP` headers are trusted for client IP resolution |
| `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) | | `REPORTS_PER_MINUTE` | `60` | Reports each client address may send a minute; see [Report limits](#report-limits) |
| `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) | | `CORS_ALLOWED_ORIGINS` | empty | Comma-separated origins whose pages may call the API; see [CORS](#cors) |
@@ -224,10 +269,10 @@ an entry that is not an address at all, such as `unknown`, the direct peer is
used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. The used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. The
container image does not use this default; see [Deployment](#deployment). container image does not use this default; see [Deployment](#deployment).
A variable set to a value the server cannot use, such as `PORT=abc`, A variable set to a value the server cannot use, such as `PORT=abc` or a
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from `BIND_ADDRESS` that is not an IP address, stops it from starting, with an error
starting, with an error naming the variable. An empty variable counts as unset, naming the variable. An empty variable counts as unset, except
except `TRUSTED_PROXIES`. `TRUSTED_PROXIES`. No value of `DEBUG` stops the start.
#### Report storage #### Report storage
@@ -241,18 +286,23 @@ toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
Files are created with `O_EXCL` to prevent overwrites. Files are created with `O_EXCL` to prevent overwrites.
The reports of a failed write stay in memory, and the next flush, within a The reports of a failed write stay in memory, and the next flush, within a
minute, tries again to write them. From the failed write until a write succeeds, minute, tries again to write them. The server writes one file at a time, so a
`POST /api/v1/reports` refuses every report with 500 and keeps nothing of it, so file holds its reports in the order they arrived, those of a failed write
the page keeps its samples and sends them again at its next report. Reports included. From the failed write until a write succeeds, `POST /api/v1/reports`
already answered 200 are lost only if the server stops before a write succeeds; refuses every report with 500 and keeps nothing of it, and the health check
the final flush at stop tries them too, and if it fails, the process exits answers 503 with `"status":"error"`. The page sends the refused samples again in
non-zero. its next report, as long as it still holds them: at the 1-second interval one
refused report already loses some of those in the history (see
[Reporting](#reporting)). Reports already answered 200 are lost only if the
server stops before a write succeeds; the final flush at stop tries them too,
and if it fails, the process exits non-zero.
#### Report limits #### Report limits
`POST /api/v1/reports` takes reports from anyone who can reach it, without `POST /api/v1/reports` takes reports from anyone who can reach it, without
credentials, so it is bounded instead. Each refusal below answers with the same credentials, so it is bounded instead. Each refusal below answers with the same
`{"status":"error"}` body as any other error. `{"status":"error"}` body as any other error, except nginx's refusal of a body
over the limit in the image.
- **JSON only.** A report must be sent with `Content-Type: application/json`, - **JSON only.** A report must be sent with `Content-Type: application/json`,
parameters such as `charset` allowed, as the page sends it; any other is parameters such as `charset` allowed, as the page sends it; any other is
@@ -260,6 +310,16 @@ credentials, so it is bounded instead. Each refusal below answers with the same
send a `text/plain` POST, or a form, without a CORS preflight; refusing them send a `text/plain` POST, or a form, without a CORS preflight; refusing them
keeps any site from making its visitors' browsers store reports (see keeps any site from making its visitors' browsers store reports (see
[CORS](#cors)). [CORS](#cors)).
- **Body limit.** A report body may be at most 1 MiB, the backend's limit for
any request. Run on its own, the backend answers 413 before the route to a
request that declares a longer body. Without a declared length, the route
answers 413 only when the report it reads runs past 1 MiB; nothing after the
end of the report is read, so a longer body whose report ends sooner is
stored, and one that stops being valid JSON sooner is answered 400. In the
image, nginx refuses a body over 1 MiB first, with or without a declared
length, as its `client_max_body_size`, which `configs/nginx.conf` does not
set, is 1 MiB by default. nginx answers 413 with its own HTML error page, and
the backend never sees the request.
- **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may - **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may
send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with
`Retry-After: 60`. The minute slides: reports from the minute before still `Retry-After: 60`. The minute slides: reports from the minute before still
@@ -283,6 +343,12 @@ credentials, so it is bounded instead. Each refusal below answers with the same
how much of the newest reports is kept: the default of 1 GiB is small enough how much of the newest reports is kept: the default of 1 GiB is small enough
for any host; set it to the space you can give `DATA_DIR`. for any host; set it to the space you can give `DATA_DIR`.
At the defaults, one address may send 60 reports of up to 1 MiB a minute, and a
report made up to compress badly takes about as much room in the report files as
its request body, so in about 17 minutes one address can fill the 1 GiB cap with
its own reports, every report stored before them deleted to make room. Lowering
`REPORTS_PER_MINUTE` or raising `DATA_DIR_MAX_BYTES` makes that take longer.
#### CORS #### CORS
The page calls the API from the origin it is served from, so by default the The page calls the API from the origin it is served from, so by default the
@@ -304,12 +370,16 @@ the metrics record its duration and response size, labelled with its path,
method and status; they also count those requests in progress, and include Go's method and status; they also count those requests in progress, and include Go's
runtime and process metrics. No other request is recorded: not those to runtime and process metrics. No other request is recorded: not those to
`/metrics` itself, and not those answered before they reach either route, such `/metrics` itself, and not those answered before they reach either route, such
as a CORS preflight, or a request refused with 404 for a path no route has, 405 as a CORS preflight, or a request refused with 404 for a path no route has or
for a method its route does not take, or 413 for declaring a body length over 405 for a method its route does not take. Run on its own, the backend also
the 1 MiB limit. A report whose body goes over the limit without declaring its answers 413 before the route to a request declaring a body length over the 1 MiB
length reaches the route, is answered 413 there, and is recorded with that limit, which is not recorded. A body over the limit sent without a declared
status. Clients can make up any number of paths and methods, and each would add length reaches the route and is recorded with the status the route answers,
labels to the metrics for as long as the server runs. which is 413 only when the report it reads runs past the limit: nothing after
the end of the report is read. In the image neither reaches the backend: nginx
answers both with 413 itself (see [Report limits](#report-limits)), and nothing
of them is recorded. Clients can make up any number of paths and methods, and
each would add labels to the metrics for as long as the server runs.
`/metrics` is rate limited, so that its password cannot be guessed quickly: each `/metrics` is rate limited, so that its password cannot be guessed quickly: each
client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a
@@ -468,7 +538,8 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
report files may take; the oldest are deleted to stay under it report files may take; the oldest are deleted to stay under it
- `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call - `CORS_ALLOWED_ORIGINS`, default empty: other origins whose pages may call
the API the API
- `DEBUG`, default `false`: debug logging - `DEBUG`, default empty: debug logging; any non-empty value, `false` and
`0` included, turns it on
- `DATA_DIR`, default `/data/reports`: the directory the reports are kept - `DATA_DIR`, default `/data/reports`: the directory the reports are kept
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`. in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
The container also stops if the path goes through a symbolic link that The container also stops if the path goes through a symbolic link that
@@ -496,9 +567,16 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
stops the container. Empty, the backend sends nothing to Sentry stops the container. Empty, the backend sends nothing to Sentry
- **Health check:** the image's `HEALTHCHECK` requests - **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless `/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
both nginx and the backend answer. upaas reads the container's health 60 both nginx and the backend answer. It also fails while reports cannot be
seconds after a deploy and fails the deploy unless it is `healthy`. The stored, as when the volume is full or `DATA_DIR` cannot be written: the health
container also stops when either process exits. check answers 503 from a failed write of a report file until a write succeeds,
and the backend tries the write again every minute. Three failed checks in a
row make the container `unhealthy`, and one that passes makes it `healthy`
again. upaas reads the container's health once, 60 seconds after a deploy, and
fails the deploy unless it is `healthy`. A container that turns `unhealthy`
after that is neither restarted nor reported by upaas, whose `unless-stopped`
restart policy ignores health; only `docker ps` and `docker inspect` show it.
The container also stops when either process exits.
## Browser Compatibility ## Browser Compatibility
+196 -86
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-04 last_modified: 2026-10-18
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -54,34 +54,39 @@ style conventions are in separate documents:
`cibuild`. `script/bootstrap` installs all dependencies idempotently and `cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk 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 (detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via installed node only when its major version is the pinned one; otherwise (node
nvm, first installing nvm itself if missing — from a hash-verified GitHub missing, or another major, such as the node 24 the CI runner image ships) it
release archive (never `curl | sh`), with bash installed as an explicit installs the PINNED node version via nvm and installs yarn and the packages
prerequisite since nvm requires bash. yarn is then pinned via under it. nvm itself is installed first if missing, from a hash-verified
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts"; GitHub release archive (never `curl | sh`), with bash installed as an explicit
always exact versions. `script/cibuild` runs the CI build: it changes to the prerequisite since nvm requires bash. Only the major version is compared
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image because the `Dockerfile` stages start from a node image whose exact version is
with the version; the Gitea workflow calls it. **`script/cibuild` runs not the pin, and nvm cannot install a prebuilt node on alpine. yarn is 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, runs `script/bootstrap`, runs `script/check`, and builds the
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs `script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**: containerised gates have passed. **The bootstrap alone is not enough**: when
`script/bootstrap` installs node and yarn under nvm and leaves neither on the `script/bootstrap` installs node and yarn under nvm it leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host `PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore under another node. The host entrypoints that need yarn — `script/fmt` and
source nvm for the pinned node version before invoking it, exactly as `script/fmt-check` — therefore use the same test as `script/bootstrap`: when
`script/bootstrap`'s own install step does. A runner carrying nothing but the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
docker and git then gets through `script/check`. Four further scripts are our and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
own extensions to the standard: `script/check` runs `script/test`, pinned node. A runner carrying nothing but docker and git then gets through
`script/lint` and `script/fmt-check`; `script/precommit` is what the git `script/check`. Four further scripts are our own extensions to the standard:
pre-commit hook runs, and it calls `script/check`; `script/install-precommit` `script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
installs the git pre-commit hook (the `make hooks` target shims to it); and `script/precommit` is what the git pre-commit hook runs, and it calls
`script/projectname` (literally that filename) simply outputs the project's `script/check`; `script/install-precommit` installs the git pre-commit hook
name. Scripts that need the name call `script/projectname` — e.g. (the `make hooks` target shims to it); and `script/projectname` (literally
`script/docker` assembles its image tag from it — so those scripts stay that filename) simply outputs the project's name. Scripts that need the name
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g. call `script/projectname` — e.g. `script/docker` assembles its image tag from
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in it — so those scripts stay byte-identical across all repos. Repo-type-specific
the hook itself. Model scripts are at pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -118,8 +123,10 @@ style conventions are in separate documents:
and nothing else: and nothing else:
```sh ```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" . docker build --build-arg CACHEBUST="$(date +%s)-$$" \
docker build --no-cache --target test -t "$(script/projectname)-test" . --target lint --output type=cacheonly .
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test --output type=cacheonly .
``` ```
**A stage that is not the last one in the file is built only when the final **A stage that is not the last one in the file is built only when the final
@@ -129,10 +136,15 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing. and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in **The gate builds write no image.** With `--output type=cacheonly` the phase
`script/cibuild` and `script/docker`. An untagged build leaves a dangling runs and a failing step fails the build, but the result is not exported.
image behind on every invocation, on every developer host and every CI Nothing uses those images, and writing one out is slow: a Go test phase's
runner; a tagged one replaces the previous image. image holds the toolchain. A build given neither `--output` nor `-t` writes
an untagged image and leaves it dangling, on every developer host and every
CI runner. `script/cibuild` and `script/docker` build the image that ships
and tag it, so each build replaces the previous image; each assigns the tag
on its own line before the build, so `set -e` stops it where
`script/projectname` fails.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are `eslint`, `prettier` — never through `make lint` or `script/test`, which are
@@ -147,22 +159,42 @@ style conventions are in separate documents:
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and org, in both directions. A container has its own `TMPDIR`, so the lock is
a digest-pinned binary, so neither is reachable. out of reach. The lint phase does keep golangci-lint's cache between check
builds (below), but it lints every checkout at `/src`, and a finding is
taken from that cache only for the same files, settings and golangci-lint
version.
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates - **Any build that runs checks is given a new `CACHEBUST` value.** Docker
a `COPY` layer only when the copied content changes, so on an unchanged tree invalidates a `COPY` layer only when the copied content changes, so on an
the check `RUN` is served from cache, nothing executes, and the build still unchanged tree the check `RUN` is served from cache, nothing executes, and the
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`: build still exits 0. Every `docker build` in `script/` therefore passes
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the `--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
four, and there is no fifth — `script/check` runs the two gate phases and id, so no two builds share a value, not even the three in one
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is `script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
not evidence that anything ran: a sub-second build reporting success is a `script/docker` are the four, and there is no fifth — `script/check` runs the
cache hit, not a result. Never invalidate by pruning — `docker builder prune` two gate phases and `script/fmt-check`, and builds no image of its own. In
and friends destroy a build cache shared with every other build on the host. every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps
When a check is added or changed, prove it works by planting a defect it must that install system packages and download Go's modules, or right after `FROM`
catch and watching the run fail on it, then revert the defect. A green run in a stage with neither, and every step that lints, tests or compiles comes
alone shows neither that the check ran nor that it covers what it should. after it. A step that installs anything else as well, such as a
`script/bootstrap` that also installs node packages, comes after it too. A new
value reruns every `RUN` step after that line; `COPY` steps are compared by
content and need nothing. Docker keeps the layers above the line until the
base image, their own lines or the files they copy change. A stage without the
`ARG` line is served from cache on an unchanged tree, so a repository
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
not pass `--no-cache`: it reruns every step too, but it also builds the layers
above the line again and empties every cache mount, so Go's build cache and
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
survive between check builds, would start empty every time. A bare
`docker build .` is not evidence that anything ran: a sub-second build
reporting success is a cache hit, not a result. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host. When a check is added or changed, prove it works by
planting a defect it must catch and watching the run fail on it, then revert
the defect. A green run alone shows neither that the check ran nor that it
covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
@@ -171,40 +203,56 @@ style conventions are in separate documents:
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# Lint phase # Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN golangci-lint run --config .golangci.yml ./... RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not. # image ships and the alpine one does not. -count=1 because the mounted
# cache also holds test results, which go test would otherwise replay.
# golang:1.x, YYYY-MM-DD # golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN go test -timeout 90s -race -cover ./... || \ RUN --mount=type=cache,target=/root/.cache/go-build \
go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; } go test -count=1 -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run # are what make BuildKit build them first, so nothing after them can
# unless lint and test passed. # run unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . . COPY . .
# The VERSION build arg when one is given, otherwise # The VERSION build arg when one is given, otherwise
@@ -212,7 +260,8 @@ style conventions are in separate documents:
# .git present, a version that is still empty, dev or unknown fails the # .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout. # build: git is missing or could not read the checkout.
ARG VERSION ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \ RUN --mount=type=cache,target=/root/.cache/go-build \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \ if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \ case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \ echo "version is '$VERSION' although .git is present" >&2; \
@@ -225,6 +274,7 @@ style conventions are in separate documents:
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
@@ -239,6 +289,26 @@ style conventions are in separate documents:
- Keep the runtime stage last, and if you add a stage after it, give it the - Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain same two copies. A plain `docker build .` builds the last stage's chain
and nothing else. and nothing else.
- Check builds keep these, and none of them can hand back a stale result:
- The layers above `ARG CACHEBUST`: the system packages and Go's
modules. Docker installs the packages again when the base image or the
install line changes, and downloads the modules again when `go.mod` or
`go.sum` changes. Go checks every module against `go.sum` as it
downloads it, and no later build can change a kept layer. The modules
are kept in a layer rather than a cache mount because a cache mount is
shared by every build on the host, and Go builds from whatever files
it holds without checking them again (only `go mod verify` does), so a
file changed there would go unnoticed.
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
root), mounted on the steps that compile: `golangci-lint run`, both
`go test` runs and `go build`. Go reuses a compiled package only when
all its inputs are unchanged. The cache also holds test results, which
is why both `go test` runs carry `-count=1`.
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
a package's findings only when the package's files and those of every
package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
@@ -247,12 +317,20 @@ style conventions are in separate documents:
in the lint phase. The `golangci/golangci-lint` image is Debian-based and in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package (`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them: lists in the same `RUN`, so the layer does not keep them. The test phase,
on the Debian Go image, installs the libraries its tests need the same
way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so
Docker keeps it until the base image or the install line changes:
```dockerfile ```dockerfile
FROM golangci/golangci-lint@sha256:... AS lint
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \ && apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
``` ```
- `.dockerignore` lets `.git` into the build context. It keeps out every git - `.dockerignore` lets `.git` into the build context. It keeps out every git
@@ -278,20 +356,39 @@ style conventions are in separate documents:
`.git` and the version still comes out empty, `dev` or `unknown`. A plain `.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that `docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument. refuses an empty build argument drops that refusal and keeps the argument.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step. runs `script/cibuild` on push, and checks out the repo as its only other step,
That script bootstraps, runs the gate phases, and then builds the image, so a with `persist-credentials: false`: `script/cibuild` needs no token, and
successful run means every check passed; a bare `docker build .` does not without it the checkout leaves the job's token in `.git/config` for every
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
tags `git describe` needs: by default it clones shallow with no tags, and a
tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
so a new push cancels the older run on the same branch, queued or running, and
no other: runs for replaced commits do not hold up the shared runner.
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
so a successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the image build gets a `CACHEBUST` value of its own and so runs the gate phases a
price of the rule above, and it is worth paying: the image that ships is built second time. That is the price of the rule above, and it is worth paying: the
from a run of its own gates rather than from a cache entry. A separate image that ships is built from a run of its own gates rather than from a cache
workflow limited to `main` by a `branches` list under `on: push` cannot be entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
checked by review: to try a change to it, add the feature branch to that list shared runner after 20 minutes. That allows for the three Docker builds
and push, then remove the branch from the list again before merging. Keep any described above (the test phase, the lint phase, then the image), each held to
job in it that publishes behind `if: github.ref_name == 'main'`, so the run the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
from the feature branch publishes nothing. limited to `main` by a `branches` list under `on: push` cannot be checked by
review: to try a change to it, add the feature branch to that list and push,
then remove the branch from the list again before merging. Keep any job in it
that publishes behind `if: github.ref_name == 'main'`, so the run from the
feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -351,12 +448,11 @@ style conventions are in separate documents:
That cache is Go's own, separate from Docker's layer cache. Go stores a That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`, run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this without running them. The directory lasts from one run to the next on a
target runs and the directory lasts from one run to the next. The `test` developer's machine, where this target runs, and in the `test` phase of the
phase of the `Dockerfile` needs no `-count=1`: its base image holds no `Dockerfile`, which mounts it to keep compiled packages between check
result for this repo's tests and nothing before its `go test` step runs a builds, so the phase's `go test` runs carry `-count=1` too. A new
test, so there is nothing to replay. `--no-cache` (above) is what makes that `CACHEBUST` value (above) is what makes that step run on an unchanged tree.
step run on an unchanged tree.
Python example: Python example:
@@ -384,9 +480,12 @@ style conventions are in separate documents:
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore` `node_modules/`, and the repo's own build outputs. Fetch the standard
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when `.gitignore` from
setting up a new repo. These patterns are written to `.gitignore`'s own `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
own entries, such as its binaries; a re-vendor replaces the standard part and
keeps those entries. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified. are not a `.dockerignore` and must not be transplanted into one unmodified.
@@ -434,13 +533,15 @@ style conventions are in separate documents:
byte-identically across repos: byte-identically across repos:
```sh ```sh
# Own line: a failing command substitution inside an argument does not # The version and the tag each get their own line: a failing command
# trip `set -e`, so the inline form degrades to an empty constant. # substitution inside an argument does not trip `set -e`, so the inline
# form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ tag="$(script/projectname)"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$(script/projectname)" . -t "$tag" .
``` ```
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
@@ -452,8 +553,8 @@ style conventions are in separate documents:
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a checkout action clones shallow and fetches no tags, so the canonical
tag-derived version must set `fetch-depth: 0` on its checkout step. `.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the - **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build patterns.** Plant files at the root _and_ at least two directories deep, build
@@ -520,6 +621,11 @@ style conventions are in separate documents:
function defined and never invoked has the same exit status and the same function defined and never invoked has the same exit status and the same
empty output as one that worked. empty output as one that worked.
Node is the exception to the whole-token comparison: the canonical
`script/bootstrap` compares only its major version, because the node 22
alpine image the `Dockerfile` stages start from is not the exact pin, and
nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to A Go tool a repo needs on the host is installed with `go install` pinned to
@@ -636,7 +742,11 @@ style conventions are in separate documents:
Never edit existing migrations after release. Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings: the standard file from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
one for another language it uses. A re-vendor replaces the standard part and
keeps those sections.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
+176 -17
View File
@@ -9,10 +9,12 @@
# Status # Status
pre-1.0. No git tags. `main` is the stable branch and `next` the development pre-1.0. Release candidates are tagged on `next`, the first being `v1.0.0-rc1`;
branch, which every PR targets. The frontend and the Go backend ship as one `git tag --merged origin/next` lists them. `main` is the stable branch and
Docker image, and the Gitea workflow `.gitea/workflows/check.yml` runs `next` the development branch, which every PR targets. The frontend and the Go
`script/cibuild` on every push. Working toward 1.0.0. backend ship as one Docker image, and the Gitea workflow
`.gitea/workflows/check.yml` runs `script/cibuild` on every push, and cancels
the older run on the same branch. Working toward 1.0.0.
# Next Step # Next Step
@@ -21,18 +23,181 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps # Completed Steps
- 2026-10-19: the server's `OnStart` hook in `internal/server/server.go` starts
`listenAndServe` with `go func() { s.listenAndServe() }()`, as the Go style
guide asks ([#162](https://git.eeqj.de/sneak/netwatch/issues/162)); it was
`go s.listenAndServe()`. No other `go` statement in the Go code called a
function directly. Nothing the server does changed
- 2026-10-19: `.gitignore`, `.editorconfig` and `.golangci.yml` are the
sneak/prompts copies at `0653579`
([#174](https://git.eeqj.de/sneak/netwatch/issues/174)). `.gitignore` is the
standard file followed by this repository's own entries; `.editorconfig` is
the standard file alone, which now carries the `[*.go]` section.
`.golangci.yml` disables `canonicalheader`, as the canonical copy does,
because golangci-lint v2.14.0 misses its findings at random
- 2026-10-19: `package.json` has no `version` field and is marked
`"private": true` ([#158](https://git.eeqj.de/sneak/netwatch/issues/158)). It
said `1.0.0`, a release that has not been made, and nothing read it; the git
tag is the one version, which the image and `make build` take from
`git describe --tags`. The package is never published
- 2026-10-19: Status names `v1.0.0-rc1`, the first release candidate, and says
release candidates are tagged on `next`, where it said there were no git tags
([#157](https://git.eeqj.de/sneak/netwatch/issues/157)). The rest of Status
matches the tree
- 2026-10-19: `netwatch-server` starts the server only when given no arguments
([#179](https://git.eeqj.de/sneak/netwatch/issues/179)). `check-cidr CIDR` and
`prepare-data-dir DATA_DIR` behave as before; any other arguments, such as a
subcommand with no value or two, or a misspelt one, print a one-line usage
message naming both forms to stderr and exit 2. They used to start the server.
Unit tests call `cli.Main` with no arguments and check that it starts the
server, and with each subcommand given no value and two values and with a
misspelt subcommand, and check the exit code, the message and that the server
does not start
- 2026-10-19: `main()` in `cmd/netwatch-server/main.go` is one call into a new
package, `internal/cli`, as the Go style guide asks
([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs
the `check-cidr` and `prepare-data-dir` subcommands, with the same arguments,
messages and exit codes, and otherwise the server, with the `fx` wiring.
`Appname` and `Version` stay in `main`, where the build sets `Version`, and
`main` gives `cli.Main` stderr and the function that runs the server, which
the tests replace with a buffer and a function that fails them. Unit tests
call `cli.Main` with `check-cidr` and a CIDR the server takes and one it
refuses, and check the exit code and what it writes to stderr, and with
`prepare-data-dir` and a path outside `/data`, and check that it exits 1 with
the error from looking up the `netwatch` user, which the test image does not
have. Others give the functions behind the subcommands input they take and
input they refuse, and check the exit code and what they write to stderr
- 2026-10-19: when `VERSION` is unset or empty, `script/build` stamps the
version from `git describe --tags --always --dirty`, as `script/docker` does
([#156](https://git.eeqj.de/sneak/netwatch/issues/156)). Without `--tags` it
skipped lightweight tags, so `make run` could report a commit where the image
built from the same tree reported the tag. Checked once with a lightweight tag
on a scratch clone: `make build` stamped it
- 2026-10-19: `.dockerignore` keeps Go test binaries and coverage output at the
root out of the build context, with `/*.test` and `/*.out` in the repository's
own section ([#155](https://git.eeqj.de/sneak/netwatch/issues/155)). Such
files went into the build context of every `docker build` in `script/` and
were copied into the lint, test and build stages. Checked with a probe image
that copies the whole context: `x.test` and `x.out` planted at the root no
longer land in it, and nothing else changed. The entries match only at the
root, so the same files in a subdirectory still reach the image
- 2026-10-19: `.gitea/workflows/check.yml` is the sneak/prompts copy at
`0653579` ([#153](https://git.eeqj.de/sneak/netwatch/issues/153)). Its
checkout step sets `fetch-depth: 0`, so CI clones all history and tags, and
`git describe` in `script/cibuild` gives the image built from a tagged commit
the tag as its version; it gave the short commit. The checkout step also sets
`persist-credentials: false`, so the job's token is not left in `.git/config`.
A new push cancels the older run on the same branch, and a run stops after 20
minutes
- 2026-10-19: `script/bootstrap` keeps the `yarn` on `PATH` only when
`yarn --version` is exactly `YARN_VERSION`, and otherwise installs that
version as it did when no yarn was installed
([#154](https://git.eeqj.de/sneak/netwatch/issues/154)); any yarn used to be
kept. It uses the `node` on `PATH` only when that node has the major version
of `NODE_VERSION`, as `REPO_POLICIES.md` asks, and is at least
`NODE_MIN_VERSION`; a node 24 was kept before. After installing node or yarn
it looks the tool up through `PATH` again and stops if that one fails the same
check, as it does for Go, and it prints the node and yarn versions it uses.
Every script that runs yarn on the host, except `script/bootstrap`, which
picks its own node and yarn, runs it through the new `script/yarn`. That
script puts `~/.local/bin` first on `PATH`, uses the first `node` on that
`PATH` when it passes the same test, and otherwise runs yarn under the pinned
node in nvm; `make build` ran yarn under whatever node was first on `PATH`
- 2026-10-19: `bin/entrypoint.sh` has each of its steps in a function, and a
`main` at the end calls them in order, as the shell section of the code style
guide asks ([#163](https://git.eeqj.de/sneak/netwatch/issues/163)). It is
still POSIX sh with no `set -e`, and does what it did, with the same messages
and exit codes. Checked by running the image with each script: a normal start,
bad `PORT`, `TRUSTED_PROXIES` and `DATA_DIR` values, `docker stop`, INT, and
nginx exiting on its own
- 2026-10-19: while report files cannot be written, the health check answers 503
with `"status":"error"` and the same fields, so the image's `HEALTHCHECK`
reports the container `unhealthy`
([#142](https://git.eeqj.de/sneak/netwatch/issues/142)). It answered 200
`"status":"ok"` whatever happened, so the platform saw a healthy container
while every report was refused with 500. It answers 200 at start, before any
write has failed, and again from the first write that succeeds. A unit test
removes `DATA_DIR` to make a write fail and checks the 503, then puts it back
and checks the 200 after the next write
- 2026-10-19: report file writes no longer overlap
([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of
failed writes are written in the order they arrived. Two writes that failed at
the same time put their reports back in the wrong order when the older failed
first. One write runs at a time, and each takes the waiting reports only when
it starts, after a failed write has put its own back. The write `Append`
starts when 10 MiB are waiting waits for any write under way, and `Append`
starts no other while it waits. Files written since the start are deleted to
make room in the order they were written, after those found at start. A unit
test holds a write open, starts a second at 10 MiB, fails both and checks the
order in the next file. Others check that `Append` starts no second write at
10 MiB while one waits, that it starts one again the next time 10 MiB are
waiting, and that the first file written since the start is the first deleted
- 2026-10-19: `DEBUG` unset or empty leaves debug logging off, and any other
value, `false` and `0` included, turns it on, as the code style guide says
([#159](https://git.eeqj.de/sneak/netwatch/issues/159)). No value of it stops
the start any more; before, only `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`,
`f`, `F`, `FALSE`, `false` and `False` were accepted. `README.md` no longer
gives `false` as its default
- 2026-10-19: check builds keep Go's modules and compiled packages between runs
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)), on sneak's rulings in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113) and
[prompts#130](https://git.eeqj.de/sneak/prompts/issues/130).
`REPO_POLICIES.md`, `script/test`, `script/lint`, `script/docker` and
`script/cibuild` are the sneak/prompts copies at `0653579`: the scripts pass a
new `CACHEBUST` value where they passed `--no-cache`, and the lint and test
builds write no image. In each stage of `Dockerfile`, `ARG CACHEBUST` comes
after the system packages and the Go module download, and before every step
that lints, tests or compiles and the node stages' `yarn install`. Go's build
cache and golangci-lint's are cache mounts, and both `go test` runs carry
`-count=1`. The test phase's Go caches used to be in memory, empty on every
build
- 2026-10-19: the three unit tests of a check whose result is discarded (its
round given up, the user paused, or the first round) also check that the
host's history is still empty, its status still `pending`, and nothing was
written to the console
([#151](https://git.eeqj.de/sneak/netwatch/issues/151)). They checked only the
row, so a change that recorded the result and skipped only the row would have
kept them passing
- 2026-10-18: when the backend runs on its own, a `DATA_DIR` that cannot be
created or read, or where no file can be created, stops the start with an
error naming `DATA_DIR` and its value, as the other settings' errors do
([#148](https://git.eeqj.de/sneak/netwatch/issues/148)); the first two named
only the path, and the third was found only at the first write of a report
file. At start the server creates a file in `DATA_DIR` and removes it. Unit
tests start the buffer with `DATA_DIR` below a regular file and with
`DATA_DIR` set to `/proc`, and check the error. The report file number is an
`atomic.Int64`, as the Go style guide asks for int types
([#161](https://git.eeqj.de/sneak/netwatch/issues/161)); file names are
unchanged
- 2026-10-18: the image's nginx sends the page with `Cache-Control: no-cache` - 2026-10-18: the image's nginx sends the page with `Cache-Control: no-cache`
([#141](https://git.eeqj.de/sneak/netwatch/issues/141)), so a browser that ([#141](https://git.eeqj.de/sneak/netwatch/issues/141)), so a browser that
keeps a copy checks with the server that it is current before using it again. keeps a copy checks with the server that it is current before using it again.
The page went out with no `Cache-Control`, so a browser could keep it after a The page went out with no `Cache-Control`, so a browser could keep it after a
deploy and ask for files under `/assets/` that the new build no longer has, deploy and ask for files under `/assets/` that the new build no longer has,
and show an empty page. A path with no file of its own falls back to the page and show an empty page. A path that `location /` answers with the page through
and gets the same header. `location /` now includes the security headers its `try_files` fallback gets the same header. `location /` now includes the
again, as `/assets/` does, since its own `add_header` drops the server's. security headers again, as `/assets/` does, since its own `add_header` drops
`/assets/` keeps its immutable caching. `make frontend-viewport-test` fails the server's. `/assets/` keeps its immutable caching.
unless `/` and a path that falls back to the page carry `make frontend-viewport-test` fails unless `/` and a path that falls back to
`Cache-Control: no-cache` and `Content-Security-Policy`, which comes from the the page carry `Cache-Control: no-cache` and `Content-Security-Policy`, which
same file as the other security headers comes from the same file as the other security headers
- 2026-10-18: `README.md` says what the page loses while reports are refused or
the page is paused ([#145](https://git.eeqj.de/sneak/netwatch/issues/145)),
how fast one address can replace the stored reports
([#146](https://git.eeqj.de/sneak/netwatch/issues/146)), and that in the image
nginx answers a body over 1 MiB itself
([#147](https://git.eeqj.de/sneak/netwatch/issues/147)). A sample in the
history that no report has delivered is sent only as long as the history holds
it: at the 1-second interval one refused or failed report loses the oldest 20
seconds of each host's samples, and a pause longer than the history loses
every sample between the last delivered report and the pause. Samples an
interval change cleared are kept apart, the last 100 per host, until they are
delivered. At the defaults, one address sending 1 MiB reports made up to
compress badly can fill the 1 GiB cap in about 17 minutes. nginx's
`client_max_body_size` is also 1 MiB by default, so in the image it answers a
larger body with 413 and its own HTML page, and the backend never sees or
records it; the backend's own 413 sentences now say they apply to it run on
its own. The reporter, the body limit and nginx are unchanged
- 2026-10-18: a browser that cannot format the header's clocks still probes - 2026-10-18: a browser that cannot format the header's clocks still probes
([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the ([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the
local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which
@@ -586,9 +751,3 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
- A configurable host list (an environment variable or a config file) - A configurable host list (an environment variable or a config file)
- Export of the latency history (CSV or JSON) - Export of the latency history (CSV or JSON)
- A notification when the health status changes to DEGRADED - A notification when the health status changes to DEGRADED
- `make test` within the 20-second target
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)): every run compiles
the Go standard library and every dependency with the race detector from
nothing. Whether test builds may keep Go's compiled packages between runs
waits on sneak's ruling in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113)
+68 -40
View File
@@ -13,24 +13,27 @@ set -u
# address and start anyway, and reports a bad port without naming # address and start anyway, and reports a bad port without naming
# PORT, so a value that is not a usable port stops the container here, # PORT, so a value that is not a usable port stops the container here,
# before either process starts. # before either process starts.
export PORT="${PORT:-8080}" check_port() {
case "$PORT" in export PORT="${PORT:-8080}"
case "$PORT" in
*[!0-9]*) *[!0-9]*)
echo "entrypoint: PORT must be a port number, not '$PORT'" >&2 echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
exit 1 exit 1
;; ;;
esac esac
# The length is checked first because, for a number too big for it, # The length is checked first because, for a number too big for it,
# the shell's test prints an error and is false, so the range checks # the shell's test prints an error and is false, so the range checks
# alone would let it through. # alone would let it through.
if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2 echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2
exit 1 exit 1
fi fi
if [ "$PORT" -eq 8081 ]; then if [ "$PORT" -eq 8081 ]; then
echo "entrypoint: PORT cannot be 8081, netwatch-server listens there" >&2 echo "entrypoint: PORT cannot be 8081, netwatch-server" \
"listens there" >&2
exit 1 exit 1
fi fi
}
# TRUSTED_PROXIES names the reverse proxies in front of the container, # TRUSTED_PROXIES names the reverse proxies in front of the container,
# as IP addresses or CIDRs separated by commas. nginx takes the client # as IP addresses or CIDRs separated by commas. nginx takes the client
@@ -47,9 +50,10 @@ fi
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one # TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
# below, naming the entry as written. set -f keeps a * in an entry from # below, naming the entry as written. set -f keeps a * in an entry from
# becoming a list of file names. # becoming a list of file names.
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" write_trusted_proxies() {
set -f TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do set -f
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in case "$proxy" in
*/*) cidr="$proxy" ;; */*) cidr="$proxy" ;;
*:*) cidr="$proxy/128" ;; *:*) cidr="$proxy/128" ;;
@@ -61,7 +65,8 @@ for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
exit 1 exit 1
fi fi
echo "set_real_ip_from $cidr;" echo "set_real_ip_from $cidr;"
done > /etc/nginx/trusted-proxies.conf done > /etc/nginx/trusted-proxies.conf
}
# netwatch-server keeps its report files in DATA_DIR, on the /data # netwatch-server keeps its report files in DATA_DIR, on the /data
# volume, which may be a host directory owned by root or by another # volume, which may be a host directory owned by root or by another
@@ -73,12 +78,10 @@ done > /etc/nginx/trusted-proxies.conf
# naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it # naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it
# acts on nothing outside /data, whatever symbolic or hard links it # acts on nothing outside /data, whatever symbolic or hard links it
# meets there. # meets there.
export DATA_DIR="${DATA_DIR:-/data/reports}" prepare_data_dir() {
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1 export DATA_DIR="${DATA_DIR:-/data/reports}"
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
# A stop signal is only noted here; the loop below acts on it. }
stop_requested=""
trap 'stop_requested=yes' TERM INT
# netwatch-server runs as the netwatch user and listens on loopback # netwatch-server runs as the netwatch user and listens on loopback
# only, on a port other than the public one; configs/nginx.conf proxies # only, on a port other than the public one; configs/nginx.conf proxies
@@ -87,9 +90,11 @@ trap 'stop_requested=yes' TERM INT
# the container has. The netwatch user has no login shell, hence -s # the container has. The netwatch user has no login shell, hence -s
# /bin/sh. busybox su replaces itself with the command instead of # /bin/sh. busybox su replaces itself with the command instead of
# staying on as its parent, so $! is the server's own PID. # staying on as its parent, so $! is the server's own PID.
BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \ start_backend() {
BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \
su -s /bin/sh netwatch -c 'exec netwatch-server' & su -s /bin/sh netwatch -c 'exec netwatch-server' &
backend=$! backend=$!
}
# nginx starts through the nginx image's own entrypoint, which applies # nginx starts through the nginx image's own entrypoint, which applies
# the image's start-up configuration and then replaces itself with # the image's start-up configuration and then replaces itself with
@@ -98,9 +103,11 @@ backend=$!
# that rendering to PORT: a variable nginx itself uses, such as $uri, # that rendering to PORT: a variable nginx itself uses, such as $uri,
# would otherwise be replaced by an environment variable of the same # would otherwise be replaced by an environment variable of the same
# name. # name.
NGINX_ENVSUBST_FILTER='^PORT$' \ start_nginx() {
NGINX_ENVSUBST_FILTER='^PORT$' \
/docker-entrypoint.sh nginx -g 'daemon off;' & /docker-entrypoint.sh nginx -g 'daemon off;' &
nginx=$! nginx=$!
}
running() { running() {
kill -0 "$1" 2>/dev/null kill -0 "$1" 2>/dev/null
@@ -109,26 +116,47 @@ running() {
# POSIX sh cannot wait for whichever of two children exits first, so # POSIX sh cannot wait for whichever of two children exits first, so
# look once a second. The shell collects a child that has exited while # look once a second. The shell collects a child that has exited while
# it runs sleep, and running() is false for that child from then on. # it runs sleep, and running() is false for that child from then on.
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do wait_for_stop_or_exit() {
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
sleep 1 sleep 1
done done
}
# Stop both, then wait until neither is left. # Stop both, then wait until neither is left.
kill -TERM "$backend" "$nginx" 2>/dev/null stop_both() {
while running "$backend" || running "$nginx"; do kill -TERM "$backend" "$nginx" 2>/dev/null
while running "$backend" || running "$nginx"; do
sleep 1 sleep 1
done done
}
wait "$backend"
backend_status=$?
wait "$nginx"
nginx_status=$?
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
# Success is a requested stop that both processes exited cleanly from. # Success is a requested stop that both processes exited cleanly from.
if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] && exit_with_status() {
wait "$backend"
backend_status=$?
wait "$nginx"
nginx_status=$?
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
[ "$nginx_status" -eq 0 ]; then [ "$nginx_status" -eq 0 ]; then
exit 0 exit 0
fi fi
exit 1 exit 1
}
main() {
check_port
write_trusted_proxies
prepare_data_dir
# A stop signal is only noted here; wait_for_stop_or_exit acts on it.
stop_requested=""
trap 'stop_requested=yes' TERM INT
start_backend
start_nginx
wait_for_stop_or_exit
stop_both
exit_with_status
}
main "$@"
+4 -74
View File
@@ -1,22 +1,11 @@
// Package main is the entry point for netwatch-server. // Package main is the entry point for netwatch-server; package cli
// does the work.
package main package main
import ( import (
"fmt"
"os" "os"
"os/user"
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/cli"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
) )
//nolint:gochecknoglobals // set via ldflags at build time //nolint:gochecknoglobals // set via ldflags at build time
@@ -26,64 +15,5 @@ var (
) )
func main() { func main() {
// "netwatch-server check-cidr CIDR" exits 1, with the error, if os.Exit(cli.Main(os.Stderr, Appname, Version, os.Args[1:], cli.RunServer))
// this server would refuse CIDR in its TRUSTED_PROXIES.
// bin/entrypoint.sh runs it on each entry it gives nginx.
if len(os.Args) == 3 && os.Args[1] == "check-cidr" {
_, err := middleware.ParseTrustedProxies(os.Args[2:])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
// for the netwatch user, or exits 1 with the error; see
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
// before it starts this server as that user.
if len(os.Args) == 3 && os.Args[1] == "prepare-data-dir" {
netwatch, err := user.Lookup("netwatch")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch)
if err != nil {
fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err)
os.Exit(1)
}
return
}
globals.Appname = Appname
globals.Version = Version
fx.New(
// fx logs each step of starting and stopping through the
// server's own logger, so off a terminal those lines are
// JSON like every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
return &fxevent.SlogLogger{Logger: log.Get()}
}),
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Invoke(
// First, so the name and version are logged even when
// a setting stops the start.
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
} }
+143
View File
@@ -0,0 +1,143 @@
// Package cli runs netwatch-server from its command line: the
// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh
// runs before it starts the server, and, given no arguments, the
// server, with the fx wiring.
package cli
import (
"fmt"
"io"
"os/user"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
)
// usageExitCode is what Main returns for a command line it does not
// understand, the usual exit code for a usage error.
const usageExitCode = 2
// Main runs netwatch-server with args, its command line after the
// program's name, and returns the exit code. With no args, Main runs
// the server by calling serve with appname and version, the name and
// version the server logs and reports. With "check-cidr CIDR" or
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its
// errors to stderr. Any other args get a one-line usage message on
// stderr and exit code 2. main passes os.Stderr and RunServer; the
// tests pass a buffer and a function of their own, so that no test
// starts the server. When serve is RunServer and the server fails to
// start, to listen or to stop, fx's Run exits the process with status 1
// and Main never returns.
func Main(
stderr io.Writer,
appname, version string,
args []string,
serve func(appname, version string),
) int {
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
// this server would refuse CIDR in its TRUSTED_PROXIES.
// bin/entrypoint.sh runs it on each entry it gives nginx.
if len(args) == 2 && args[0] == "check-cidr" {
return checkCIDR(stderr, args[1])
}
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
// for the netwatch user, or exits 1 with the error; see
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
// before it starts this server as that user.
if len(args) == 2 && args[0] == "prepare-data-dir" {
return prepareDataDir(stderr, "/data", args[1], "netwatch")
}
// Any other command line is refused, so that a mistyped one does
// not start the server.
if len(args) > 0 {
_, _ = fmt.Fprintln(stderr,
"usage: netwatch-server [check-cidr CIDR | prepare-data-dir DATA_DIR]")
return usageExitCode
}
serve(appname, version)
return 0
}
// checkCIDR returns 0 if the server would take cidr in its
// TRUSTED_PROXIES, and otherwise writes the error to stderr and
// returns 1.
func checkCIDR(stderr io.Writer, cidr string) int {
_, err := middleware.ParseTrustedProxies([]string{cidr})
if err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 1
}
return 0
}
// prepareDataDir gets dataDir ready for the user named username with
// reportbuf.PrepareDataDir, volume being where the data volume is
// mounted, and returns 0. Otherwise it writes the error to stderr and
// returns 1.
func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
owner, err := user.Lookup(username)
if err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 1
}
err = reportbuf.PrepareDataDir(volume, dataDir, owner)
if err != nil {
_, _ = fmt.Fprintf(stderr, "DATA_DIR '%s': %v\n", dataDir, err)
return 1
}
return 0
}
// RunServer runs the server until it is told to stop, with appname and
// version as the name and version it logs and reports. If the server
// fails to start, to listen or to stop, fx's Run exits the process
// with status 1 itself.
func RunServer(appname, version string) {
globals.Appname = appname
globals.Version = version
fx.New(
// fx logs each step of starting and stopping through the
// server's own logger, so off a terminal those lines are
// JSON like every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
return &fxevent.SlogLogger{Logger: log.Get()}
}),
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Invoke(
// First, so the name and version are logged even when
// a setting stops the start.
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
}
+219
View File
@@ -0,0 +1,219 @@
package cli_test
import (
"bytes"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/cli"
"sneak.berlin/go/netwatch/internal/reportbuf"
)
// TestMainRunsCheckCIDR: Main given check-cidr returns 0, writing
// nothing, for a CIDR the server takes in its TRUSTED_PROXIES, and 1,
// with an error naming TRUSTED_PROXIES and the CIDR, for one it
// refuses, and does not start the server.
func TestMainRunsCheckCIDR(t *testing.T) {
t.Parallel()
var stderr bytes.Buffer
code := cli.Main(&stderr, "netwatch", "test",
[]string{"check-cidr", "10.0.0.0/8"}, failIfServerStarts(t))
if code != 0 || stderr.Len() != 0 {
t.Errorf("check-cidr 10.0.0.0/8: exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
stderr.Reset()
code = cli.Main(&stderr, "netwatch", "test",
[]string{"check-cidr", "1.2.3/32"}, failIfServerStarts(t))
want := `TRUSTED_PROXIES "1.2.3/32": `
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
t.Errorf("check-cidr 1.2.3/32: exit %d, stderr %q; want exit 1 and %q...",
code, stderr.String(), want)
}
}
// TestMainRunsPrepareDataDir: Main given prepare-data-dir runs the
// prepare-data-dir subcommand for the netwatch user, and does not start
// the server. The test image has no netwatch user, so that subcommand
// exits 1 with the error from looking the user up;
// TestPrepareDataDirRefusesPathOutsideVolume checks the path.
func TestMainRunsPrepareDataDir(t *testing.T) {
t.Parallel()
var stderr bytes.Buffer
code := cli.Main(&stderr, "netwatch", "test",
[]string{"prepare-data-dir", "/etc"}, failIfServerStarts(t))
want := user.UnknownUserError("netwatch").Error() + "\n"
if code != 1 || stderr.String() != want {
t.Errorf("prepare-data-dir /etc: exit %d, stderr %q; want exit 1 and %q",
code, stderr.String(), want)
}
}
// TestMainRunsServerWithNoArguments: Main given no arguments starts the
// server with the name and version it was given, writes nothing and
// returns 0.
func TestMainRunsServerWithNoArguments(t *testing.T) {
t.Parallel()
var (
stderr bytes.Buffer
gotAppname, gotVersion string
)
code := cli.Main(&stderr, "netwatch", "test", []string{},
func(appname, version string) {
gotAppname, gotVersion = appname, version
})
if code != 0 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
if gotAppname != "netwatch" || gotVersion != "test" {
t.Errorf("server started with %q %q; want %q %q",
gotAppname, gotVersion, "netwatch", "test")
}
}
// TestMainRefusesOtherCommandLines: Main given a subcommand with too
// few or too many values, or a misspelt subcommand, writes the usage
// message to stderr, returns 2 and does not start the server.
func TestMainRefusesOtherCommandLines(t *testing.T) {
t.Parallel()
want := "usage: netwatch-server " +
"[check-cidr CIDR | prepare-data-dir DATA_DIR]\n"
for _, commandLine := range []string{
"netwatch-server check-cidr",
"netwatch-server check-cidr 192.0.2.0/24 198.51.100.0/24",
"netwatch-server prepare-data-dir",
"netwatch-server prepare-data-dir /data/reports /data/other",
"netwatch-server check-cdir 192.0.2.0/24",
} {
var stderr bytes.Buffer
// Main takes the command line after the program's name.
args := strings.Fields(commandLine)[1:]
code := cli.Main(&stderr, "netwatch", "test", args, failIfServerStarts(t))
if code != 2 || stderr.String() != want {
t.Errorf("%q: exit %d, stderr %q; want exit 2 and %q",
commandLine, code, stderr.String(), want)
}
}
}
// TestCheckCIDRTakesCIDR: check-cidr exits 0, writing nothing, for a
// CIDR the server takes in its TRUSTED_PROXIES.
func TestCheckCIDRTakesCIDR(t *testing.T) {
t.Parallel()
for _, cidr := range []string{"10.0.0.0/8", "192.0.2.1/32", "2001:db8::/32"} {
var stderr bytes.Buffer
code := cli.CheckCIDR(&stderr, cidr)
if code != 0 || stderr.Len() != 0 {
t.Errorf("%q: exit %d, stderr %q; want exit 0 and no output",
cidr, code, stderr.String())
}
}
}
// TestCheckCIDRRefusesOtherInput: check-cidr exits 1, with an error
// naming TRUSTED_PROXIES and the input, for anything the server would
// refuse in its TRUSTED_PROXIES, such as 1.2.3/32, which nginx would
// look up as a hostname.
func TestCheckCIDRRefusesOtherInput(t *testing.T) {
t.Parallel()
for _, cidr := range []string{
"1.2.3/32", "example.com/32", "10.0.0.0/33", "10.0.0.1", "",
} {
var stderr bytes.Buffer
code := cli.CheckCIDR(&stderr, cidr)
want := "TRUSTED_PROXIES " + strconv.Quote(cidr) + ": "
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
t.Errorf("%q: exit %d, stderr %q; want exit 1 and %q...",
cidr, code, stderr.String(), want)
}
}
}
// TestPrepareDataDirTakesPathInVolume: prepare-data-dir exits 0,
// writing nothing, for a DATA_DIR below the volume, and creates it.
func TestPrepareDataDirTakesPathInVolume(t *testing.T) {
t.Parallel()
volume := t.TempDir()
dataDir := filepath.Join(volume, "reports")
var stderr bytes.Buffer
code := cli.PrepareDataDir(&stderr, volume, dataDir, currentUsername(t))
if code != 0 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
info, err := os.Stat(dataDir)
if err != nil || !info.IsDir() {
t.Errorf("DATA_DIR not created: %v", err)
}
}
// TestPrepareDataDirRefusesPathOutsideVolume: prepare-data-dir exits
// 1, with an error naming DATA_DIR and its value, for a DATA_DIR
// outside the volume.
func TestPrepareDataDirRefusesPathOutsideVolume(t *testing.T) {
t.Parallel()
volume := t.TempDir()
var stderr bytes.Buffer
code := cli.PrepareDataDir(&stderr, volume, "/etc", currentUsername(t))
want := "DATA_DIR '/etc': " + reportbuf.ErrDataDirOutsideVolume.Error() + "\n"
if code != 1 || stderr.String() != want {
t.Errorf("exit %d, stderr %q; want exit 1 and %q",
code, stderr.String(), want)
}
}
// currentUsername is the name of the user the test runs as, the only
// owner a test not run as root can give files to.
func currentUsername(t *testing.T) string {
t.Helper()
current, err := user.Current()
if err != nil {
t.Fatal(err)
}
return current.Username
}
// failIfServerStarts is what the tests give Main in place of
// cli.RunServer: it fails the test instead of starting the server.
func failIfServerStarts(t *testing.T) func(appname, version string) {
t.Helper()
return func(string, string) {
t.Error("Main started the server")
}
}
+16
View File
@@ -0,0 +1,16 @@
package cli
import "io"
// CheckCIDR exposes the check-cidr subcommand to the external tests,
// which read what it writes to stderr.
func CheckCIDR(stderr io.Writer, cidr string) int {
return checkCIDR(stderr, cidr)
}
// PrepareDataDir exposes the prepare-data-dir subcommand to the
// external tests, which give it a volume and a user of their own in
// place of /data and netwatch.
func PrepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
return prepareDataDir(stderr, volume, dataDir, username)
}
+8 -11
View File
@@ -41,7 +41,6 @@ var (
"must be an origin, scheme://host with an optional port", "must be an origin, scheme://host with an optional port",
) )
errNotPort = errors.New("must be a port number, 1 to 65535") errNotPort = errors.New("must be a port number, 1 to 65535")
errNotBool = errors.New("must be true or false")
errNotIP = errors.New("must be an IP address, or empty") errNotIP = errors.New("must be an IP address, or empty")
errMetricsCredentials = errors.New( errMetricsCredentials = errors.New(
@@ -101,7 +100,7 @@ func New(
viper.SetDefault("CORS_ALLOWED_ORIGINS", "") viper.SetDefault("CORS_ALLOWED_ORIGINS", "")
viper.SetDefault("DATA_DIR", "./data/reports") viper.SetDefault("DATA_DIR", "./data/reports")
viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes) viper.SetDefault("DATA_DIR_MAX_BYTES", defaultDataDirMaxBytes)
viper.SetDefault("DEBUG", "false") viper.SetDefault("DEBUG", "")
// An empty BIND_ADDRESS listens on every interface. // An empty BIND_ADDRESS listens on every interface.
viper.SetDefault("BIND_ADDRESS", "") viper.SetDefault("BIND_ADDRESS", "")
viper.SetDefault("PORT", "8080") viper.SetDefault("PORT", "8080")
@@ -120,19 +119,17 @@ func New(
} }
} }
// Read with strconv: viper's GetInt and GetBool would read a value // Read with strconv: viper's GetInt would read a value it cannot
// they cannot parse as 0 or false instead of failing. // parse as 0 instead of failing.
port, err := strconv.Atoi(viper.GetString("PORT")) port, err := strconv.Atoi(viper.GetString("PORT"))
if err != nil || port < 1 || port > math.MaxUint16 { if err != nil || port < 1 || port > math.MaxUint16 {
return nil, fmt.Errorf("PORT %q: %w", return nil, fmt.Errorf("PORT %q: %w",
viper.GetString("PORT"), errNotPort) viper.GetString("PORT"), errNotPort)
} }
debug, err := strconv.ParseBool(viper.GetString("DEBUG")) // Any non-empty value turns debug logging on, "false" and "0"
if err != nil { // included, as the code style guide says.
return nil, fmt.Errorf("DEBUG %q: %w", debug := viper.GetString("DEBUG") != ""
viper.GetString("DEBUG"), errNotBool)
}
// An empty TRUSTED_PROXIES trusts no proxy, but viper reads an // An empty TRUSTED_PROXIES trusts no proxy, but viper reads an
// empty environment variable as unset and would give the default, // empty environment variable as unset and would give the default,
@@ -172,8 +169,8 @@ func New(
} }
// check fails with an error naming the first setting here whose value // check fails with an error naming the first setting here whose value
// the server cannot use. New checks PORT and DEBUG as it reads them, // the server cannot use. New checks PORT as it reads it, and the
// and the middleware checks TRUSTED_PROXIES as it parses it. // middleware checks TRUSTED_PROXIES as it parses it.
func (s *Config) check() error { func (s *Config) check() error {
// viper reads a value that is not a number as 0, so this also // viper reads a value that is not a number as 0, so this also
// catches a mistyped setting. // catches a mistyped setting.
+49 -9
View File
@@ -13,8 +13,8 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
) )
// requireConfigError builds the config as main does and fails the // requireConfigError builds the config as cli.RunServer does and fails
// test unless that fails with an error naming setting. It uses // the test unless that fails with an error naming setting. It uses
// fx.New, because fxtest.New fails the test itself on an error. // fx.New, because fxtest.New fails the test itself on an error.
func requireConfigError(t *testing.T, setting string) { func requireConfigError(t *testing.T, setting string) {
t.Helper() t.Helper()
@@ -31,8 +31,8 @@ func requireConfigError(t *testing.T, setting string) {
} }
} }
// requireTrustedProxies builds the config as main does and fails the // requireTrustedProxies builds the config as cli.RunServer does and
// test unless its TrustedProxies are want. // fails the test unless its TrustedProxies are want.
func requireTrustedProxies(t *testing.T, want []string) { func requireTrustedProxies(t *testing.T, want []string) {
t.Helper() t.Helper()
@@ -127,12 +127,52 @@ func TestPortMustBeAPortNumber(t *testing.T) {
} }
} }
// TestDebugMustBeTrueOrFalse: viper reads any other value, such as // TestDebugIsOnForAnyNonEmptyValue: unset or empty, debug logging is
// "yes", as false. // off; any other value turns it on, "false" and "0" included, and none
func TestDebugMustBeTrueOrFalse(t *testing.T) { // stops the start.
t.Setenv("DEBUG", "yes") func TestDebugIsOnForAnyNonEmptyValue(t *testing.T) {
for _, tc := range []struct {
name string
value string
unset bool
want bool
}{
{name: "unset", unset: true, want: false},
{name: "empty", value: "", want: false},
{name: "false", value: "false", want: true},
{name: "0", value: "0", want: true},
{name: "yes", value: "yes", want: true},
} {
t.Run(tc.name, func(t *testing.T) {
// t.Setenv first, so the variable is put back as it was
// after the test.
t.Setenv("DEBUG", tc.value)
requireConfigError(t, "DEBUG") if tc.unset {
err := os.Unsetenv("DEBUG")
if err != nil {
t.Fatal(err)
}
}
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
err := app.Err()
if err != nil {
t.Fatalf("config error = %v", err)
}
if cfg.Debug != tc.want {
t.Fatalf("Debug = %t, want %t", cfg.Debug, tc.want)
}
})
}
} }
// TestBindAddressMustBeAnIPAddress: a host name would be looked up // TestBindAddressMustBeAnIPAddress: a host name would be looked up
+1 -1
View File
@@ -4,7 +4,7 @@ package globals
import "go.uber.org/fx" import "go.uber.org/fx"
//nolint:gochecknoglobals // set from main before fx starts //nolint:gochecknoglobals // set by cli.RunServer before fx starts
var ( var (
// Appname is the application name. // Appname is the application name.
Appname string Appname string
+10 -2
View File
@@ -3,9 +3,17 @@ package handlers
import "net/http" import "net/http"
// HandleHealthCheck returns a handler for the health check // HandleHealthCheck returns a handler for the health check
// endpoint. // endpoint. It answers 200 while the status is "ok" and 503 otherwise,
// so that the image's HEALTHCHECK fails.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc { func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
s.respondJSON(w, r, s.hc.Healthcheck(), http.StatusOK) resp := s.hc.Healthcheck()
code := http.StatusOK
if resp.Status != "ok" {
code = http.StatusServiceUnavailable
}
s.respondJSON(w, r, resp, code)
} }
} }
+22 -9
View File
@@ -9,17 +9,19 @@ import (
"testing" "testing"
"time" "time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers" "sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck" "sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
) )
// newStartedHandlers builds Handlers with a real health check for the // newStartedHandlers builds Handlers with a real health check for the
// server named in g, and starts them, which records the time the // server named in g, and a report buffer in a temporary directory, and
// uptime counts from. // starts them, which records the time the uptime counts from.
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers { func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Helper() t.Helper()
@@ -30,14 +32,23 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Fatalf("logger: %v", err) t.Fatalf("logger: %v", err)
} }
buf, err := reportbuf.New(lc, reportbuf.Params{
Config: &config.Config{DataDir: t.TempDir(), DataDirMaxBytes: 1 << 20},
Logger: log,
})
if err != nil {
t.Fatalf("report buffer: %v", err)
}
hc, err := healthcheck.New(lc, hc, err := healthcheck.New(lc,
healthcheck.Params{Globals: g, Logger: log}) healthcheck.Params{Buffer: buf, Globals: g, Logger: log})
if err != nil { if err != nil {
t.Fatalf("health check: %v", err) t.Fatalf("health check: %v", err)
} }
h, err := handlers.New(lc, h, err := handlers.New(lc, handlers.Params{
handlers.Params{Globals: g, Healthcheck: hc, Logger: log}) Buffer: buf, Globals: g, Healthcheck: hc, Logger: log,
})
if err != nil { if err != nil {
t.Fatalf("handlers: %v", err) t.Fatalf("handlers: %v", err)
} }
@@ -48,10 +59,12 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
return h return h
} }
// TestHandleHealthCheck checks the health check's answer: 200, a JSON // TestHandleHealthCheck checks the health check's answer at start, when
// content type, and a JSON object with exactly the fields of // no write of a report file has failed: 200, a JSON content type, and a
// healthcheck.HealthcheckResponse, carrying this server's name and // JSON object with exactly the fields of healthcheck.HealthcheckResponse,
// version and an uptime counted from its start. // carrying "status":"ok", this server's name and version and an uptime
// counted from its start. The answer while writes fail is checked in
// internal/reportbuf, whose tests can make a write fail.
func TestHandleHealthCheck(t *testing.T) { func TestHandleHealthCheck(t *testing.T) {
t.Parallel() t.Parallel()
+10 -2
View File
@@ -10,6 +10,7 @@ import (
"sneak.berlin/go/netwatch/internal/config" "sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals" "sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger" "sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx" "go.uber.org/fx"
) )
@@ -18,6 +19,7 @@ import (
type Params struct { type Params struct {
fx.In fx.In
Buffer *reportbuf.Buffer
Config *config.Config Config *config.Config
Globals *globals.Globals Globals *globals.Globals
Logger *logger.Logger Logger *logger.Logger
@@ -68,12 +70,18 @@ func New(
return s, nil return s, nil
} }
// Healthcheck returns the current health status of the application. // Healthcheck returns the current health status of the application:
// "ok", or "error" while report files cannot be written.
func (s *Healthcheck) Healthcheck() *HealthcheckResponse { func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
status := "ok"
if s.params.Buffer.LastWriteFailed() {
status = "error"
}
return &HealthcheckResponse{ return &HealthcheckResponse{
Appname: s.params.Globals.Appname, Appname: s.params.Globals.Appname,
Now: time.Now().UTC().Format(time.RFC3339Nano), Now: time.Now().UTC().Format(time.RFC3339Nano),
Status: "ok", Status: status,
UptimeHuman: s.uptime().String(), UptimeHuman: s.uptime().String(),
UptimeSeconds: int64(s.uptime().Seconds()), UptimeSeconds: int64(s.uptime().Seconds()),
Version: s.params.Globals.Version, Version: s.params.Globals.Version,
+83 -46
View File
@@ -16,7 +16,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"sync/atomic" "sync/atomic"
@@ -73,11 +72,9 @@ type Buffer struct {
// except in tests that hold the write open or make it fail. // except in tests that hold the write open or make it fail.
fileCreated func(f *os.File) fileCreated func(f *os.File)
// files are the report files that may be deleted to make room, // files are the report files that may be deleted to make room,
// in name order, which is oldest first: those in dataDir at // oldest first: those in dataDir at start, in name order, then
// start, and each one this buffer writes, put in at its place by // each one this buffer writes, once it is complete. A file still
// name once it is complete, even when an older file's write // being written is not among them. filesBytes is their total size.
// completes after a newer one's. A file still being written is
// not among them. filesBytes is their total size.
files []reportFile files []reportFile
filesBytes int64 filesBytes int64
log *slog.Logger log *slog.Logger
@@ -88,8 +85,12 @@ type Buffer struct {
now func() time.Time now func() time.Time
// seq numbers the report files, so that two named in the same // seq numbers the report files, so that two named in the same
// millisecond still get different names. // millisecond still get different names.
seq atomic.Uint64 seq atomic.Int64
stopOnce sync.Once stopOnce sync.Once
// thresholdWriteWaiting is set while a write Append started at
// flushSizeThreshold waits for writeMu; Append starts no other
// while it is set.
thresholdWriteWaiting atomic.Bool
// usedBytes is what Append checks against maxBytes: the size // usedBytes is what Append checks against maxBytes: the size
// of the report files in dataDir, plus the reports waiting to // of the report files in dataDir, plus the reports waiting to
// be written to one at their uncompressed size. // be written to one at their uncompressed size.
@@ -99,6 +100,11 @@ type Buffer struct {
// refuses reports, and the reports of that write wait in buf for // refuses reports, and the reports of that write wait in buf for
// the next flush. // the next flush.
writeErr error writeErr error
// writeMu is held across each write of a report file, so writes
// never overlap: a failed write puts its reports back in buf before
// the next write takes them, which keeps them in the order they
// were appended.
writeMu sync.Mutex
// writers are the goroutines that write report files: flushLoop, // writers are the goroutines that write report files: flushLoop,
// and each one Append starts at flushSizeThreshold. OnStop waits // and each one Append starts at flushSizeThreshold. OnStop waits
// for them before the final flush. // for them before the final flush.
@@ -127,16 +133,33 @@ func New(
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
// The errors name DATA_DIR, as config.New's do for the
// settings it checks.
err := os.MkdirAll(b.dataDir, dirPerms) err := os.MkdirAll(b.dataDir, dirPerms)
if err != nil { if err != nil {
return fmt.Errorf("create data dir: %w", err) return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
}
// Creating and removing a file stops the start when no
// file can be created in DATA_DIR, instead of the first
// write of a report file failing after reports were
// answered 200. A full disk is not caught: the empty
// file is still created.
f, err := os.CreateTemp(b.dataDir, "")
if err != nil {
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
}
err = errors.Join(f.Close(), os.Remove(f.Name()))
if err != nil {
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
} }
// Report files left by earlier runs count too, and are // Report files left by earlier runs count too, and are
// the first to be deleted to make room. // the first to be deleted to make room.
files, err := reportFiles(b.dataDir) files, err := reportFiles(b.dataDir)
if err != nil { if err != nil {
return err return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
} }
b.mu.Lock() b.mu.Lock()
@@ -190,8 +213,9 @@ func New(
// write of a report file failed, until a write succeeds again. If the // write of a report file failed, until a write succeeds again. If the
// line would take usedBytes past maxBytes, the oldest report files are // line would take usedBytes past maxBytes, the oldest report files are
// deleted to make room; it stores nothing and returns ErrFull if that // deleted to make room; it stores nothing and returns ErrFull if that
// cannot make room. If the buffer reaches the size threshold, it is // cannot make room. If the buffer reaches the size threshold, it starts
// drained and written to disk asynchronously. // a write of the reports in the background (see flushAtThreshold),
// unless one it started is still waiting.
func (b *Buffer) Append(v any) error { func (b *Buffer) Append(v any) error {
line, err := json.Marshal(v) line, err := json.Marshal(v)
if err != nil { if err != nil {
@@ -221,18 +245,9 @@ func (b *Buffer) Append(v any) error {
b.buf.Write(line) b.buf.Write(line)
b.buf.WriteByte('\n') b.buf.WriteByte('\n')
if b.buf.Len() >= flushSizeThreshold { if b.buf.Len() >= flushSizeThreshold &&
data := b.drainBuf() b.thresholdWriteWaiting.CompareAndSwap(false, true) {
b.mu.Unlock() b.writers.Go(b.flushAtThreshold)
b.writers.Go(func() {
writeErr := b.writeFile(data)
if writeErr != nil {
b.log.Error("flush reports failed", "error", writeErr)
}
})
return nil
} }
b.mu.Unlock() b.mu.Unlock()
@@ -240,6 +255,16 @@ func (b *Buffer) Append(v any) error {
return nil return nil
} }
// LastWriteFailed reports whether the last write of a report file to
// finish failed: false at start until a write fails, and false again
// once a write succeeds. While it is true, Append refuses reports.
func (b *Buffer) LastWriteFailed() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.writeErr != nil
}
// deleteOldestFiles deletes report files, oldest first, until n more // deleteOldestFiles deletes report files, oldest first, until n more
// bytes fit under maxBytes. It deletes none when the reports waiting // bytes fit under maxBytes. It deletes none when the reports waiting
// to be written leave no room for n even with every file gone, since // to be written leave no room for n even with every file gone, since
@@ -290,21 +315,28 @@ func (b *Buffer) flushLoop() {
} }
} }
// flushLocked acquires the lock, drains the buffer, and // flushLocked waits for any write of a report file under way, then
// writes the data to a compressed file. // writes the reports waiting in the buffer to a new one.
func (b *Buffer) flushLocked() error { func (b *Buffer) flushLocked() error {
b.mu.Lock() b.writeMu.Lock()
defer b.writeMu.Unlock()
if b.buf.Len() == 0 { return b.writeFile()
b.mu.Unlock() }
return nil // flushAtThreshold is the write Append starts when the buffer reaches
// flushSizeThreshold. Once any write under way is done, it lets Append
// start another and writes the reports waiting in the buffer.
func (b *Buffer) flushAtThreshold() {
b.writeMu.Lock()
defer b.writeMu.Unlock()
b.thresholdWriteWaiting.Store(false)
err := b.writeFile()
if err != nil {
b.log.Error("flush reports failed", "error", err)
} }
data := b.drainBuf()
b.mu.Unlock()
return b.writeFile(data)
} }
// drainBuf copies the buffer contents and resets it. // drainBuf copies the buffer contents and resets it.
@@ -317,10 +349,22 @@ func (b *Buffer) drainBuf() []byte {
return data return data
} }
// writeFile writes data, reports drained from the buffer, to a new // writeFile takes the reports waiting in the buffer, if any, and writes
// timestamped zstd-compressed JSONL file in the data directory. If the // them to a new timestamped zstd-compressed JSONL file in the data
// write fails, it puts the reports back in the buffer. // directory. If the write fails, it puts the reports back in the
func (b *Buffer) writeFile(data []byte) error { // buffer. The caller must hold b.writeMu.
func (b *Buffer) writeFile() error {
b.mu.Lock()
if b.buf.Len() == 0 {
b.mu.Unlock()
return nil
}
data := b.drainBuf()
b.mu.Unlock()
// The timestamp comes first, so the names sort by time; the number // The timestamp comes first, so the names sort by time; the number
// after it tells apart files named in the same millisecond. // after it tells apart files named in the same millisecond.
ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z") ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z")
@@ -351,14 +395,7 @@ func (b *Buffer) writeFile(data []byte) error {
b.usedBytes -= int64(len(data)) b.usedBytes -= int64(len(data))
b.usedBytes += size b.usedBytes += size
b.filesBytes += size b.filesBytes += size
b.files = append(b.files, reportFile{name: name, size: size})
// At its place by name, not at the end: another write, of a newer
// file, may have completed while this one was being written.
i, _ := slices.BinarySearchFunc(b.files, name,
func(f reportFile, target string) int {
return strings.Compare(f.name, target)
})
b.files = slices.Insert(b.files, i, reportFile{name: name, size: size})
return nil return nil
} }
+394 -114
View File
@@ -108,6 +108,68 @@ func TestFailedFinalFlushFailsStop(t *testing.T) {
} }
} }
// TestUncreatableDataDirStopsStart sets DATA_DIR below a regular file,
// where no directory can be created, even by root, as the tests run in
// the backend image. The start must fail with an error naming DATA_DIR
// and its value, as the errors for the other settings do.
func TestUncreatableDataDirStopsStart(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
writeBytes(t, file, 1)
dir := filepath.Join(file, "reports")
t.Setenv("DATA_DIR", dir)
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Invoke(func(*reportbuf.Buffer) {}),
)
err := app.Start(t.Context())
if err == nil {
t.Fatal("start succeeded with DATA_DIR below a regular file")
}
want := fmt.Sprintf("DATA_DIR %q: ", dir)
if !strings.Contains(err.Error(), want) {
t.Fatalf("start error = %q, want it to contain %q", err, want)
}
}
// TestUnwritableDataDirStopsStart sets DATA_DIR to /proc, which exists
// and can be read, but where no file can be created, even by root, as
// the tests run in the backend image: root ignores a directory the test
// makes read-only. The start must fail with an error naming DATA_DIR and
// its value, rather than the first write of a report file.
func TestUnwritableDataDirStopsStart(t *testing.T) {
dir := "/proc"
t.Setenv("DATA_DIR", dir)
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Invoke(func(*reportbuf.Buffer) {}),
)
err := app.Start(t.Context())
if err == nil {
t.Fatal("start succeeded with DATA_DIR /proc")
}
want := fmt.Sprintf("DATA_DIR %q: ", dir)
if !strings.Contains(err.Error(), want) {
t.Fatalf("start error = %q, want it to contain %q", err, want)
}
}
// TestStopWaitsForPeriodicWrite holds open the write the periodic flush // TestStopWaitsForPeriodicWrite holds open the write the periodic flush
// starts, then stops the app. The stop must wait until the write is // starts, then stops the app. The stop must wait until the write is
// released, and the report must then be in its file. The test runs in // released, and the report must then be in its file. The test runs in
@@ -272,9 +334,9 @@ func stopWhileHeld(
return <-stopped return <-stopped
} }
// startApp starts a Buffer through fx, as main does, with the DATA_DIR // startApp starts a Buffer through fx, as cli.RunServer does, with the
// and DATA_DIR_MAX_BYTES the calling test has set, and returns it with // DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set, and returns
// the app, for tests that stop the app themselves. // it with the app, for tests that stop the app themselves.
func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) { func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
t.Helper() t.Helper()
@@ -296,8 +358,8 @@ func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
return buf, app return buf, app
} }
// startBuffer starts a Buffer through fx, as main does, with the // startBuffer starts a Buffer through fx, as cli.RunServer does, with
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set. // the DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
func startBuffer(t *testing.T) *reportbuf.Buffer { func startBuffer(t *testing.T) *reportbuf.Buffer {
t.Helper() t.Helper()
@@ -519,6 +581,62 @@ func TestWrittenFilesDeletedToMakeRoom(t *testing.T) {
} }
} }
// TestWrittenFilesDeletedOldestFirst writes three report files, then
// appends a report that fits only once one of them is deleted. The
// first one written must be the one deleted.
func TestWrittenFilesDeletedOldestFirst(t *testing.T) {
const maxBytes = 1000
report := map[string]string{"id": "written"}
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes))
buf := startBuffer(t)
var written []string
buf.OnFileCreated(func(f *os.File) { written = append(written, f.Name()) })
for range 3 {
err := buf.Append(report)
if err != nil {
t.Fatalf("append report: %v", err)
}
err = buf.Flush()
if err != nil {
t.Fatalf("flush: %v", err)
}
}
// The three files hold the same report, so they are the same size.
info, err := os.Stat(written[0])
if err != nil {
t.Fatalf("stat %s: %v", written[0], err)
}
// A report that fits beside two of the files, so deleting one makes
// exactly the room it needs.
pad := maxBytes - 2*int(info.Size()) -
lineBytes(t, map[string]string{"id": ""})
err = buf.Append(map[string]string{"id": strings.Repeat("a", pad)})
if err != nil {
t.Fatalf("report that needs room: %v", err)
}
if exists(t, written[0]) {
t.Fatal("first report file written kept when room was needed")
}
for _, path := range written[1:] {
if !exists(t, path) {
t.Fatalf("%s deleted before the first report file written", path)
}
}
}
// TestFailedDeletionStillCounts makes deleting the oldest report file // TestFailedDeletionStillCounts makes deleting the oldest report file
// fail. It is still there, so it still takes room, and the next oldest // fail. It is still there, so it still takes room, and the next oldest
// is deleted in its place. // is deleted in its place.
@@ -604,17 +722,16 @@ func TestFileDeletedByHandFreesRoom(t *testing.T) {
} }
} }
// TestFileBeingWrittenIsNeverDeleted holds the write of one report file // TestFileBeingWrittenIsNeverDeleted holds the write of a report file
// open while a second write completes, then sends a report that needs // open under a cap with room for two reports, then appends a second
// room. Deleting either file would make it, and the one being written is // report and a third that needs room. The file being written is the
// the older, but only the complete one may be deleted. Once the first // only report file, and it is not deleted to make room, so the third
// write is complete, its file is deleted when room is needed. // report is refused. Once the write is complete, its file is deleted
// when room is needed.
func TestFileBeingWrittenIsNeverDeleted(t *testing.T) { func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
report := map[string]string{"id": "writing"} report := map[string]string{"id": "writing"}
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
// Room for two reports waiting to be written, but not for two
// beside a report file.
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*lineBytes(t, report))) t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(2*lineBytes(t, report)))
buf := startBuffer(t) buf := startBuffer(t)
@@ -639,36 +756,25 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
writing := <-created writing := <-created
// Only the first write is held; the second goes through, and // Errorf, not Fatalf, until the write is released, so that a
// so do the writes after it, the final one at stop included.
var complete string
buf.OnFileCreated(func(f *os.File) { complete = f.Name() })
// Errorf, not Fatalf, until the first write is released, so that a
// failure here does not leave it held. // failure here does not leave it held.
err = buf.Append(report) err = buf.Append(report)
if err != nil { if err != nil {
t.Errorf("second report: %v", err) t.Errorf("second report: %v", err)
} }
err = buf.Flush()
if err != nil {
t.Errorf("flush of the second report: %v", err)
}
err = buf.Append(report) err = buf.Append(report)
if err != nil { if !errors.Is(err, reportbuf.ErrFull) {
t.Errorf("report that needs room: %v", err) t.Errorf("report that needs room: error = %v, want ErrFull", err)
} }
if !exists(t, writing) { if !exists(t, writing) {
t.Error("report file deleted while it was being written") t.Error("report file deleted while it was being written")
} }
if exists(t, complete) { // Only this write is held; the writes after it, the final one at
t.Error("complete report file kept, though room was needed") // stop included, go through.
} buf.OnFileCreated(func(*os.File) {})
close(release) close(release)
@@ -677,9 +783,11 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
t.Fatalf("flush of the first report: %v", err) t.Fatalf("flush of the first report: %v", err)
} }
// The second report still waits to be written, so this one fits
// only once the file just written is deleted.
err = buf.Append(report) err = buf.Append(report)
if err != nil { if err != nil {
t.Fatalf("report after the first write was complete: %v", err) t.Fatalf("report after the write was complete: %v", err)
} }
if exists(t, writing) { if exists(t, writing) {
@@ -821,6 +929,82 @@ func TestFailedWriteKeepsReportsAppendedDuringIt(t *testing.T) {
} }
} }
// TestFailedWritesKeepReportsInOrder holds a write open, then appends a
// report that takes the buffer to FlushSizeThreshold, so that Append
// starts a second write. The held write fails first, then the second,
// which waited for it and took both reports. The next flush writes them
// in the order they were appended, the held write's first.
func TestFailedWritesKeepReportsInOrder(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf := startBuffer(t)
created := make(chan struct{})
release := make(chan struct{})
// Each write is held until release is closed, then fails, as its
// file is closed under it.
buf.OnFileCreated(func(f *os.File) {
created <- struct{}{}
<-release
_ = f.Close()
})
err := buf.Append(map[string]string{"id": "held"})
if err != nil {
t.Fatalf("report of the held write: %v", err)
}
flushed := make(chan error)
go func() { flushed <- buf.Flush() }()
<-created
big := strings.Repeat("a", reportbuf.FlushSizeThreshold)
// Errorf, not Fatalf, until the held write is released, so that a
// failure here does not leave it held.
err = buf.Append(map[string]string{"id": big})
if err != nil {
t.Errorf("report that starts the second write: %v", err)
}
close(release)
err = <-flushed
if err == nil {
t.Error("held write succeeded, though its file was closed under it")
}
// The second write has created its file, so it has taken the
// reports.
<-created
// Writes from here on, the final one at stop included, succeed.
buf.OnFileCreated(func(*os.File) {})
// Flush waits for the second write to fail before it writes.
err = buf.Flush()
if err != nil {
t.Fatalf("flush after the failed writes: %v", err)
}
files, err := readReportFiles(dir)
if err != nil {
t.Fatalf("read report files: %v", err)
}
want := `{"id":"held"}` + "\n" + `{"id":"` + big + `"}` + "\n"
if len(files) != 1 || files[0] != want {
t.Fatalf("%d report files, want one holding the held write's "+
"report, then the second one", len(files))
}
}
// TestReportsRefusedWhileWritesFail posts reports through the report // TestReportsRefusedWhileWritesFail posts reports through the report
// handler, as the page does. Once a write has failed, a report is // handler, as the page does. Once a write has failed, a report is
// refused with 500 until a write succeeds again; the reports taken // refused with 500 until a write succeeds again; the reports taken
@@ -890,9 +1074,54 @@ func TestReportsRefusedWhileWritesFail(t *testing.T) {
} }
} }
// TestHealthCheckFailsWhileWritesFail asks the health check handler
// for its answer around a failed write. It answers 200 with
// "status":"ok" at start, 503 with "status":"error" from the failed
// write until a write succeeds, and 200 with "status":"ok" again after.
func TestHealthCheckFailsWhileWritesFail(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf, h := startBufferWithHandlers(t)
checkHealthCheck(t, h, "at start", http.StatusOK, "ok")
// A flush writes a file only when a report is waiting.
err := buf.Append(map[string]string{"id": "kept"})
if err != nil {
t.Fatalf("append: %v", err)
}
// With the data directory gone, the write has nowhere to go.
err = os.Remove(dir)
if err != nil {
t.Fatalf("remove data dir: %v", err)
}
err = buf.Flush()
if err == nil {
t.Fatal("flush succeeded with the data directory gone")
}
checkHealthCheck(t, h, "after the failed write",
http.StatusServiceUnavailable, "error")
err = os.Mkdir(dir, 0o750)
if err != nil {
t.Fatalf("make data dir: %v", err)
}
err = buf.Flush()
if err != nil {
t.Fatalf("flush with the data directory back: %v", err)
}
checkHealthCheck(t, h, "after a write succeeded", http.StatusOK, "ok")
}
// startBufferWithHandlers starts a Buffer and the Handlers that store // startBufferWithHandlers starts a Buffer and the Handlers that store
// reports in it through fx, as main does, with the DATA_DIR the calling // reports in it through fx, as cli.RunServer does, with the DATA_DIR
// test has set. // the calling test has set.
func startBufferWithHandlers( func startBufferWithHandlers(
t *testing.T, t *testing.T,
) (*reportbuf.Buffer, *handlers.Handlers) { ) (*reportbuf.Buffer, *handlers.Handlers) {
@@ -937,6 +1166,39 @@ func postReport(t *testing.T, h *handlers.Handlers, clientID string) int {
return rec.Code return rec.Code
} }
// checkHealthCheck asks the health check handler for its answer, and
// fails the test, saying when it asked, unless the answer has status
// code wantCode and a "status" field of wantStatus.
func checkHealthCheck(
t *testing.T,
h *handlers.Handlers,
when string,
wantCode int,
wantStatus string,
) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
h.HandleHealthCheck().ServeHTTP(rec, req)
var body struct {
Status string `json:"status"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("health check %s: body not JSON: %v (%q)",
when, err, rec.Body.String())
}
if rec.Code != wantCode || body.Status != wantStatus {
t.Fatalf("health check %s: %d with status %q, want %d with %q",
when, rec.Code, body.Status, wantCode, wantStatus)
}
}
// TestConcurrentAppendsStopAtCap appends from many goroutines at once // TestConcurrentAppendsStopAtCap appends from many goroutines at once
// with room for exactly roomFor reports: exactly that many must be // with room for exactly roomFor reports: exactly that many must be
// taken, which holds only if Append checks and counts each report // taken, which holds only if Append checks and counts each report
@@ -1088,6 +1350,106 @@ func TestFlushAtSizeThreshold(t *testing.T) {
} }
} }
// TestEachSizeThresholdStartsAWrite reaches FlushSizeThreshold twice,
// with no call to Flush and the periodic flush a minute away. Append
// must start a write each time, so that the reports end up in two
// report files. The test runs in a synctest bubble, so that
// synctest.Wait returns once each write is done.
func TestEachSizeThresholdStartsAWrite(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
synctest.Test(t, func(t *testing.T) {
buf := startBuffer(t)
var appended string
for range 2 {
appended = appendToThreshold(t, buf)
synctest.Wait()
}
files, err := readReportFiles(dir)
if err != nil {
t.Fatalf("read report files: %v", err)
}
if !slices.Equal(files, []string{appended, appended}) {
t.Fatalf("%d report files, want two, each holding the %d "+
"bytes appended to reach the threshold",
len(files), len(appended))
}
})
}
// TestAppendStartsOneWaitingWrite holds a write open, then appends two
// reports of FlushSizeThreshold bytes each. Append starts a write at
// the first, which waits for the held one, and none at the second,
// since that write is still waiting. Every write fails, so a write
// started at the second report would find the reports back in the
// buffer and try them again: once the held write is released, two
// writes must have been started, the held one and one more.
func TestAppendStartsOneWaitingWrite(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
synctest.Test(t, func(t *testing.T) {
buf := startBuffer(t)
var writes atomic.Int64
release := make(chan struct{})
// Each write is held until release is closed, then fails, as its
// file is closed under it.
buf.OnFileCreated(func(f *os.File) {
writes.Add(1)
<-release
_ = f.Close()
})
err := buf.Append(map[string]string{"id": "one-waiting"})
if err != nil {
t.Fatalf("report of the held write: %v", err)
}
go func() { _ = buf.Flush() }()
// Wait returns once the flush's write is held.
synctest.Wait()
big := map[string]string{
"id": strings.Repeat("a", reportbuf.FlushSizeThreshold),
}
// Errorf, not Fatalf, until the held write is released, so that a
// failure here does not leave it held.
for range 2 {
err = buf.Append(big)
if err != nil {
t.Errorf("report of FlushSizeThreshold bytes: %v", err)
}
}
close(release)
// Wait returns once every write started is done.
synctest.Wait()
started := writes.Load()
// Writes from here on, the final one at stop included, succeed.
buf.OnFileCreated(func(*os.File) {})
if started != 2 {
t.Fatalf("%d writes started, want 2: the held one and one "+
"at the threshold", started)
}
})
}
// appendToThreshold appends reports to buf until it holds // appendToThreshold appends reports to buf until it holds
// FlushSizeThreshold bytes, so that the last append starts writing them // FlushSizeThreshold bytes, so that the last append starts writing them
// to a report file, and returns the lines appended. // to a report file, and returns the lines appended.
@@ -1233,85 +1595,3 @@ func hasReportFile(t *testing.T, dir string) bool {
return false return false
} }
// TestFilesDeletedOldestFirstWhenWritesOverlap holds the write of an
// older report file open until a newer one's write completes, then
// releases it. When room is needed, the older file is deleted first,
// though its write was the last to complete.
func TestFilesDeletedOldestFirstWhenWritesOverlap(t *testing.T) {
const maxBytes = 1000
report := map[string]string{"id": "overlap"}
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(maxBytes))
buf := startBuffer(t)
created := make(chan string)
release := make(chan struct{})
buf.OnFileCreated(func(f *os.File) {
created <- f.Name()
<-release
})
err := buf.Append(report)
if err != nil {
t.Fatalf("older report: %v", err)
}
flushed := make(chan error)
go func() { flushed <- buf.Flush() }()
older := <-created
// Only the older write is held; the newer one goes through, and
// so do the writes after it, the final one at stop included.
var newer string
buf.OnFileCreated(func(f *os.File) { newer = f.Name() })
// Errorf, not Fatalf, until the older write is released, so that a
// failure here does not leave it held.
err = buf.Append(report)
if err != nil {
t.Errorf("newer report: %v", err)
}
err = buf.Flush()
if err != nil {
t.Errorf("flush of the newer report: %v", err)
}
close(release)
err = <-flushed
if err != nil {
t.Fatalf("flush of the older report: %v", err)
}
info, err := os.Stat(newer)
if err != nil {
t.Fatalf("stat %s: %v", newer, err)
}
// A report that fits beside the newer file alone, so deleting the
// older one makes exactly the room it needs.
pad := maxBytes - int(info.Size()) - lineBytes(t, map[string]string{"id": ""})
err = buf.Append(map[string]string{"id": strings.Repeat("a", pad)})
if err != nil {
t.Fatalf("report that needs room: %v", err)
}
if exists(t, older) {
t.Fatal("older report file kept when room was needed")
}
if !exists(t, newer) {
t.Fatal("newer report file deleted before the older one")
}
}
+3 -3
View File
@@ -22,9 +22,9 @@ import (
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
) )
// newServer builds a Server from the same constructors as main, // newServer builds a Server from the same constructors as
// configured from the environment. It is never started, so nothing // cli.RunServer, configured from the environment. It is never started,
// listens. // so nothing listens.
func newServer(t *testing.T) *server.Server { func newServer(t *testing.T) *server.Server {
t.Helper() t.Helper()
+1 -1
View File
@@ -79,7 +79,7 @@ func New(
s.SetupRoutes() s.SetupRoutes()
s.httpServer = s.newHTTPServer() s.httpServer = s.newHTTPServer()
go s.listenAndServe() go func() { s.listenAndServe() }()
return nil return nil
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "netwatch", "name": "netwatch",
"version": "1.0.0", "private": true,
"description": "Real-time network latency monitor SPA", "description": "Real-time network latency monitor SPA",
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -23,8 +23,8 @@ main() {
"" | -* | *[[:space:]]*) usage ;; "" | -* | *[[:space:]]*) usage ;;
esac esac
cd "$ROOT" cd "$ROOT"
yarn add --dev "$1" "$ROOT/script/yarn" add --dev "$1"
yarn install --frozen-lockfile "$ROOT/script/yarn" install --frozen-lockfile
} }
main "$@" main "$@"
+55 -14
View File
@@ -3,11 +3,14 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is # or apk (detected in that order); assumes nothing is present. Node is
# used directly if it is at least NODE_MIN_VERSION; otherwise it is # used directly only if it has the major version of NODE_VERSION and is
# installed at a pinned version via nvm (installing nvm itself first, # at least NODE_MIN_VERSION; otherwise NODE_VERSION is installed via nvm
# from a hash-verified release archive, never curl | sh). Go, with its # (installing nvm itself first, from a hash-verified release archive,
# gofmt, is used directly only if it is exactly GO_VERSION; otherwise # never curl | sh). Yarn is used directly only if it is exactly
# that release is installed from its hash-verified archive. # YARN_VERSION; otherwise that version is prepared through corepack,
# or installed with npm where there is no corepack.
# Go, with its gofmt, is used directly only if it is exactly GO_VERSION;
# otherwise that release is installed from its hash-verified archive.
# #
# What this script installs outside the system package manager lives # What this script installs outside the system package manager lives
# under $HOME and is linked into ~/.local/bin, where make and the git # under $HOME and is linked into ~/.local/bin, where make and the git
@@ -142,32 +145,63 @@ ensure_nvm() {
rm -rf "$tmp" rm -rf "$tmp"
} }
# node_ok: the node on PATH is at least NODE_MIN_VERSION. node itself # node_ok: the node on PATH has the major version of NODE_VERSION and is
# compares the two: major, then minor, then patch. # at least NODE_MIN_VERSION. Only the major is compared with the pin,
# as REPO_POLICIES.md asks. node itself compares the versions: major,
# then minor, then patch. A node that fails does not pass.
# script/bootstrap and script/yarn carry this function unchanged.
node_ok() { node_ok() {
if missing node; then return 1; fi if ! command -v node >/dev/null 2>&1; then return 1; fi
node -e ' node -e '
const have = process.versions.node.split(".").map(Number); const have = process.versions.node.split(".").map(Number);
const want = process.argv[1].split(".").map(Number); const want = process.argv[1].split(".").map(Number);
const pinned = process.argv[2].split(".").map(Number);
if (have[0] !== pinned[0]) process.exit(1);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1); if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
} }
' "$NODE_MIN_VERSION" ' "$NODE_MIN_VERSION" "$NODE_VERSION"
} }
# ensure_node: unless node_ok, install NODE_VERSION and link its node. # ensure_node: unless node_ok, install NODE_VERSION and link its node.
# Afterwards node is looked up through PATH again and must pass node_ok.
ensure_node() { ensure_node() {
if node_ok; then return 0; fi if node_ok; then
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm ensure_nvm
nvm_sh "nvm install $NODE_VERSION" nvm_sh "nvm install $NODE_VERSION"
link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node
hash -r
if ! node_ok; then
echo "bootstrap: after installing node $NODE_VERSION through nvm," >&2
echo " the node on PATH, $(command -v node), is not it" >&2
exit 1
fi
echo "bootstrap: installed node $(node --version)"
} }
# ensure_yarn: corepack writes its shims (pnpm and yarnpkg as well as # yarn_ok: yarn --version on PATH prints exactly YARN_VERSION, compared
# yarn) into $TOOLCHAIN rather than next to itself, and the npm fallback # over the whole version. A yarn that fails or prints anything else does
# installs there too; only yarn is linked. # not pass.
yarn_ok() {
if missing yarn; then return 1; fi
version="$(yarn --version 2>/dev/null)" || return 1
[ "$version" = "$YARN_VERSION" ]
}
# ensure_yarn: unless yarn_ok, prepare YARN_VERSION and link its yarn.
# corepack writes its shims (pnpm and yarnpkg as well as yarn) into
# $TOOLCHAIN rather than next to itself, and the npm fallback installs
# there too; only yarn is linked. Afterwards yarn is looked up through
# PATH again and must pass yarn_ok, so another yarn that hides the link
# stops bootstrap.
ensure_yarn() { ensure_yarn() {
if ! missing yarn; then return 0; fi if yarn_ok; then
echo "bootstrap: using yarn $(yarn --version)"
return 0
fi
shims="$TOOLCHAIN/corepack-shims" shims="$TOOLCHAIN/corepack-shims"
mkdir -p "$shims" mkdir -p "$shims"
if ! missing corepack; then if ! missing corepack; then
@@ -182,6 +216,13 @@ ensure_yarn() {
shims="$TOOLCHAIN/npm-global/bin" shims="$TOOLCHAIN/npm-global/bin"
fi fi
link_bin "$shims/yarn" yarn link_bin "$shims/yarn" yarn
hash -r
if ! yarn_ok; then
echo "bootstrap: after installing yarn $YARN_VERSION in $shims," >&2
echo " the yarn on PATH, $(command -v yarn), does not report it" >&2
exit 1
fi
echo "bootstrap: installed yarn $(yarn --version)"
} }
# go_ok: the go on PATH has its gofmt beside it (a Go release ships the # go_ok: the go on PATH has its gofmt beside it (a Go release ships the
+5 -4
View File
@@ -8,11 +8,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn build "$ROOT/script/yarn" build
# VERSION comes from the environment. Unset or empty, it is git # VERSION comes from the environment. Unset or empty, it is
# describe, or "dev" where there is no git or no repository history. # `git describe --tags --always --dirty`, as in script/docker, or
version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}" # "dev" where there is no git or no repository history.
version="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
CGO_ENABLED=0 go build -trimpath \ CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.Version=$version" \ -ldflags "-s -w -X main.Version=$version" \
+14 -9
View File
@@ -2,9 +2,12 @@
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs # checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do. # the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the # A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# final stage depends on are RUN steps, and a cached one is a check that # line, since a cached gate phase is a check that did not run; unlike
# did not run. # --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -14,15 +17,17 @@ main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" "$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check" "$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does # The version and the tag each get their own line: a failing
# not trip `set -e`, so the inline form degrades silently to an # command substitution inside an argument does not trip `set -e`,
# empty constant. The VERSION build argument takes precedence over # so the inline form degrades silently to an empty constant. The
# the version a build stage derives from the .git in the context. # VERSION build argument takes precedence over the version a build
# stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$tag" .
} }
main "$@" main "$@"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn dev "$ROOT/script/yarn" dev
} }
main "$@" main "$@"
+14 -8
View File
@@ -1,8 +1,12 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN # A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# steps, and a cached one is a check that did not run. # line, since a cached gate phase is a check that did not run; unlike
# --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,15 +14,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# Own line: a failing command substitution inside an argument does # The version and the tag each get their own line: a failing
# not trip `set -e`, so the inline form degrades silently to an # command substitution inside an argument does not trip `set -e`,
# empty constant. The VERSION build argument takes precedence over # so the inline form degrades silently to an empty constant. The
# the version a build stage derives from the .git in the context. # VERSION build argument takes precedence over the version a build
# stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
docker build --no-cache \ tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" . -t "$tag" .
} }
main "$@" main "$@"
+1 -1
View File
@@ -10,7 +10,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn prettier --write '**/*.{js,css,html,md}' "$ROOT/script/yarn" prettier --write '**/*.{js,css,html,md}'
} }
main "$@" main "$@"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
yarn prettier --check '**/*.{js,css,html,md}' "$ROOT/script/yarn" prettier --check '**/*.{js,css,html,md}'
} }
main "$@" main "$@"
+5 -3
View File
@@ -14,12 +14,14 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || { NODE_OPTIONS=--test-reporter=dot \
timeout 90 "$ROOT/script/yarn" --silent run test || {
echo "--- Rerunning with every test listed for details ---" echo "--- Rerunning with every test listed for details ---"
NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test NODE_OPTIONS=--test-reporter=spec \
timeout 90 "$ROOT/script/yarn" --silent run test
exit 1 exit 1
} }
timeout 30 yarn build timeout 30 "$ROOT/script/yarn" build
} }
main "$@" main "$@"
+15 -8
View File
@@ -77,16 +77,23 @@ main() {
-v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \ -v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \
"$SERVER_IMAGE" > /dev/null "$SERVER_IMAGE" > /dev/null
# The page, and a path with no file of its own, which falls back to # The page, and /no-such-page, which location / answers with the
# it, must go out with Cache-Control: no-cache. That add_header in # page through its try_files fallback, must go out with
# its location drops the server's own unless the location includes # Cache-Control: no-cache. That add_header in its location drops the
# the security headers again; only Content-Security-Policy is # server's own unless the location includes the security headers
# checked, as it comes from the same file as the rest. curl tries up # again; only Content-Security-Policy is checked, as it comes from
# to ten more times, a second apart, while nginx starts. # the same file as the rest. curl tries up to ten more times, a
# second apart, while nginx starts. It runs without -S, which would
# print an error for every failed attempt, even when a later one
# succeeds.
for path in / /no-such-page; do for path in / /no-such-page; do
headers="$(docker exec "$SERVER" curl -sS -o /dev/null -D - \ if ! headers="$(docker exec "$SERVER" curl -s -o /dev/null -D - \
--retry 10 --retry-connrefused --retry-delay 1 \ --retry 10 --retry-connrefused --retry-delay 1 \
"http://127.0.0.1:8080$path" | tr -d '\r')" "http://127.0.0.1:8080$path")"; then
echo "frontend-viewport-test: nginx did not answer $path" >&2
exit 1
fi
headers="$(printf '%s\n' "$headers" | tr -d '\r')"
if ! printf '%s\n' "$headers" | grep -qix 'Cache-Control: no-cache' || if ! printf '%s\n' "$headers" | grep -qix 'Cache-Control: no-cache' ||
! printf '%s\n' "$headers" | grep -qi '^Content-Security-Policy:'; then ! printf '%s\n' "$headers" | grep -qi '^Content-Security-Policy:'; then
echo "frontend-viewport-test: $path needs" \ echo "frontend-viewport-test: $path needs" \
+10 -5
View File
@@ -5,9 +5,14 @@
# make its answer untrustworthy. # make its answer untrustworthy.
# #
# The phase is not the last stage in the file, so it is built only when # The phase is not the last stage in the file, so it is built only when
# --target names it. --no-cache because a cached lint layer is a lint # --target names it. A new CACHEBUST value reruns every RUN step after
# that did not run. The tag makes each build replace the previous image # the ARG CACHEBUST line, since a cached lint layer is a lint that did
# instead of leaving a dangling one behind. # not run; unlike --no-cache, it keeps the layers above that line and
# the cache mounts. This relies on every stage of the Dockerfile
# declaring ARG CACHEBUST before every step that lints, tests or
# compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning.
# --output type=cacheonly writes no image, since nothing uses one.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -15,9 +20,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target lint \ --target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" . --output type=cacheonly .
} }
main "$@" main "$@"
+9 -4
View File
@@ -2,8 +2,13 @@
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint: # and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when # --target because a phase that is not the last stage is built only when
# named, --no-cache because a cached test layer is a test that did not # named, and a new CACHEBUST value because a cached test layer is a test
# run, and a tag so each build replaces the previous image. # that did not run; unlike --no-cache, it keeps the layers above the ARG
# CACHEBUST line and the cache mounts. This relies on every stage of the
# Dockerfile declaring ARG CACHEBUST before every step that lints, tests
# or compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning. --output type=cacheonly writes no
# image, since nothing uses one.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,9 +16,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --no-cache \ docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test \ --target test \
-t "$("$SCRIPT_DIR/projectname")-test" . --output type=cacheonly .
} }
main "$@" main "$@"
Executable
+55
View File
@@ -0,0 +1,55 @@
#!/bin/sh
# script/yarn: run yarn with the given arguments under the node
# script/bootstrap would use. Every script that runs yarn on the host
# runs it through this one, except script/bootstrap, which picks its
# own node and yarn.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pins in script/bootstrap.
NODE_VERSION="22.17.0"
NODE_MIN_VERSION="22.13.0"
# node_ok: the node on PATH has the major version of NODE_VERSION and is
# at least NODE_MIN_VERSION. Only the major is compared with the pin,
# as REPO_POLICIES.md asks. node itself compares the versions: major,
# then minor, then patch. A node that fails does not pass.
# script/bootstrap and script/yarn carry this function unchanged.
node_ok() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
node -e '
const have = process.versions.node.split(".").map(Number);
const want = process.argv[1].split(".").map(Number);
const pinned = process.argv[2].split(".").map(Number);
if (have[0] !== pinned[0]) process.exit(1);
for (let i = 0; i < 3; i++) {
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
}
' "$NODE_MIN_VERSION" "$NODE_VERSION"
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH passes node_ok, and otherwise yarn under the pinned node
# in nvm. nvm is a bash script, hence the subshell.
run_yarn() {
if node_ok && command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if node_ok || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "script/yarn: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
# script/bootstrap links the node and yarn it installs into
# ~/.local/bin, which the shell that called it may not have on PATH.
PATH="$HOME/.local/bin:$PATH"
run_yarn "$@"
}
main "$@"
+22 -9
View File
@@ -83,6 +83,11 @@ function statusText(state, host) {
return elements[`.status-text[data-host="${index}"]`]?.innerHTML; return elements[`.status-text[data-host="${index}"]`]?.innerHTML;
} }
// What this test wrote to the browser console with console[method].
function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[0]);
}
// Mocks the clock for test t, so that a check lasting seconds takes no real // Mocks the clock for test t, so that a check lasting seconds takes no real
// time, and replaces fetch with targets that each answer after // time, and replaces fetch with targets that each answer after
// answerAfter(url) milliseconds of that clock, or never when that is // answerAfter(url) milliseconds of that clock, or never when that is
@@ -202,9 +207,10 @@ test("at a 30000ms interval, a target answering after 1000ms shows in its row wh
}); });
// In the next three tests, the answering target's check is still waiting // In the next three tests, the answering target's check is still waiting
// when something happens after which its result must not show. // when something happens after which its result must not be recorded or
// show in its row, and the check must write nothing to the console.
test("at a 30000ms interval, a check still waiting when its round is given up does not show in its row", async (t) => { test("at a 30000ms interval, a check still waiting when its round is given up is not recorded and does not show in its row", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
{ name: "Answering", url: "https://answering.test" }, { name: "Answering", url: "https://answering.test" },
@@ -220,9 +226,13 @@ test("at a 30000ms interval, a check still waiting when its round is given up do
roundChecks.abort(); roundChecks.abort();
assert.notEqual(await settled(round), "still waiting"); assert.notEqual(await settled(round), "still waiting");
assert.equal(latencyFigure(state, answering), undefined); assert.equal(latencyFigure(state, answering), undefined);
assert.deepEqual(answering.history, []);
assert.equal(answering.status, "pending");
assert.deepEqual(consoleLines("error"), []);
assert.deepEqual(consoleLines("info"), []);
}); });
test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => { test("at a 30000ms interval, a check still waiting when the user pauses is not recorded and does not show in its row", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
{ name: "Answering", url: "https://answering.test" }, { name: "Answering", url: "https://answering.test" },
@@ -237,9 +247,13 @@ test("at a 30000ms interval, a check still waiting when the user pauses does not
t.mock.timers.tick(500); t.mock.timers.tick(500);
assert.equal(await settled(round), "still waiting"); assert.equal(await settled(round), "still waiting");
assert.equal(latencyFigure(state, answering), undefined); assert.equal(latencyFigure(state, answering), undefined);
assert.deepEqual(answering.history, []);
assert.equal(answering.status, "pending");
assert.deepEqual(consoleLines("error"), []);
assert.deepEqual(consoleLines("info"), []);
}); });
test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => { test("at a 30000ms interval, a check in the first round is not recorded and does not show in its row", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
{ name: "Answering", url: "https://answering.test" }, { name: "Answering", url: "https://answering.test" },
@@ -251,6 +265,10 @@ test("at a 30000ms interval, a check in the first round does not show in its row
t.mock.timers.tick(1000); t.mock.timers.tick(1000);
assert.equal(await settled(round), "still waiting"); assert.equal(await settled(round), "still waiting");
assert.equal(latencyFigure(state, answering), undefined); assert.equal(latencyFigure(state, answering), undefined);
assert.deepEqual(answering.history, []);
assert.equal(answering.status, "pending");
assert.deepEqual(consoleLines("error"), []);
assert.deepEqual(consoleLines("info"), []);
}); });
test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => { test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => {
@@ -300,11 +318,6 @@ function stateWithOneTarget() {
return { state, target: state.local[0] }; return { state, target: state.local[0] };
} }
// What this test wrote to the browser console with console[method].
function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[0]);
}
test("a check that fails with a network error writes one console line with the target, the time, the error and how long the request took", async (t) => { test("a check that fails with a network error writes one console line with the target, the time, the error and how long the request took", async (t) => {
const { state, target } = stateWithOneTarget(); const { state, target } = stateWithOneTarget();
mockTargets(t, () => 23, target.url); mockTargets(t, () => 23, target.url);