Author SHA1 Message Date
sneak d60f537304 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 03:16:47 +00: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
clawbot 007190b541 Clock errors no longer stop probing; legend reads >=500ms (closes #149, closes #152)
check / check (push) Waiting to run
The header's clocks start last in init, after the rounds, and an error
formatting them, as in a browser that rejects the "shortGeneric" time
zone name, leaves both clocks blank instead of stopping the page before
its first round. updateClocks moved out of init so a unit test can call
it.

The footer legend's red entry reads >=500ms, as README.md and
CONFIG.latencyColors have it. buildUI is exported so a unit test can draw
the page and check the legend against every entry of
CONFIG.latencyColors.

Model: opus-5-5
2026-10-19 01:34:45 +02:00
clawbot fd32f348b7 prepare-data-dir leaves files with a second name to their owner (closes #143)
check / check (push) Waiting to run
A hard link in /data shares its owner with a file that can be anywhere
on the same filesystem, so chowning it gave that file to the netwatch
user. A file with more than one name now keeps its owner and its path
is logged.

Owners are now set on the opened directory, file or link after
checking it, as chmodDir sets modes, so a hard link swapped in
afterwards is caught. Every other file is opened without following it;
a symbolic link is given to the netwatch user itself, and devices,
named pipes and sockets keep their owner. This is built only for
Linux: prepare-data-dir runs only in the Linux image, and macOS has
neither O_PATH nor AT_EMPTY_PATH; elsewhere it returns an error. A
failed owner change names the file.

Model: opus-5-5
2026-10-19 01:11:41 +02:00
clawbot 2532da55e9 Client address is the right-most untrusted X-Forwarded-For entry (closes #144)
check / check (push) Waiting to run
From a peer in TRUSTED_PROXIES, clientIP took the left-most
X-Forwarded-For entry, which the client writes itself behind a proxy
that appends to the header, so a client could choose the address the
rate limit counts. It now walks the entries from the right past trusted
proxies and takes the first that is not one. An entry with a port is
read as its address; with only trusted entries, or one that is not an
address at all, it uses the peer. Every X-Forwarded-For header line is
read, and X-Real-IP only when there is no X-Forwarded-For entry.

Model: opus-5-5
2026-10-19 00:28:18 +02:00
21 changed files with 1258 additions and 414 deletions
+39 -17
View File
@@ -10,11 +10,22 @@
# phase covers the frontend as well, through a copy from a node stage.
# Inside them each tool is invoked directly, never through make or
# 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
# lint phase below runs it.
# node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint
ARG CACHEBUST
WORKDIR /app
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
@@ -29,8 +40,11 @@ FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a924
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
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
# phase run it.
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:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test
ARG CACHEBUST
WORKDIR /app
COPY package.json yarn.lock ./
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
# 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.
# Go's module and build caches are in memory (tmpfs) for the go test
# step alone, so the image make test tags does not carry them and the
# 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.
# -count=1 because the mounted cache also holds test results, which go
# test would otherwise replay.
# golang:1.25.7-trixie, 2026-10-07
FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . .
RUN --mount=type=tmpfs,target=/go/pkg/mod \
--mount=type=tmpfs,target=/root/.cache/go-build \
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 ---"; \
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
# this phase run its tests.
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
COPY --from=lint /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
ARG CACHEBUST
RUN yarn build
# Backend build stage. Nothing is wanted from the two phases; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# are what make BuildKit build them first, so nothing after them can
# run unless lint and test passed.
# golang:1.25-alpine (2026-02-27)
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
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
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 . .
# 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
# not read the checkout.
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 \
case "$version" in ""|dev|unknown) \
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.
# nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
ARG CACHEBUST
# netwatch-server runs as this user, which owns the report directory.
# nginx keeps the image's own arrangement: its main process runs as
+81 -22
View File
@@ -58,11 +58,11 @@ either one is broken. We provide:
defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes
- `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/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
with the race detector and coverage. The frontend's production build is left
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
- `script/fmt` — format all files (writes): prettier over the JavaScript, CSS,
HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the
@@ -86,15 +86,29 @@ either one is broken. We provide:
- `script/frontend-fmt-check` — check prettier formatting (read-only)
- `script/frontend-viewport-test` — responsive-layout verification of the built
frontend in a containerised headless Chrome (see
[test/viewport/README.md](test/viewport/README.md)). Not part of
[test/viewport/README.md](test/viewport/README.md)). Before the browser
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
`script/check`: it takes minutes.
- `script/docker` — build the image from `Dockerfile` without the build cache,
tagged `netwatch` via `script/projectname`
- `script/docker` — build the image from `Dockerfile`, tagged `netwatch` via
`script/projectname`
- `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/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
The narrow-viewport layout lives in the `max-width: 768px` media block in
@@ -153,8 +167,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
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,
retried at the next interval, never blocking probing. The report-building step
is a pure function of host state.
never blocking probing. The report-building step is a pure function of host
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
@@ -239,18 +271,22 @@ toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
Files are created with `O_EXCL` to prevent overwrites.
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,
`POST /api/v1/reports` refuses every report with 500 and keeps nothing of it, so
the page keeps its samples and sends them again at its next report. 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.
minute, tries again to write them. The server writes one file at a time, so a
file holds its reports in the order they arrived, those of a failed write
included. From the failed write until a write succeeds, `POST /api/v1/reports`
refuses every report with 500 and keeps nothing of it. The page sends the
refused samples again in 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
`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
`{"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`,
parameters such as `charset` allowed, as the page sends it; any other is
@@ -258,6 +294,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
keeps any site from making its visitors' browsers store reports (see
[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
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
@@ -281,6 +327,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
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
The page calls the API from the origin it is served from, so by default the
@@ -302,12 +354,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
runtime and process metrics. No other request is recorded: not those to
`/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
for a method its route does not take, or 413 for declaring a body length over
the 1 MiB limit. A report whose body goes over the limit without declaring its
length reaches the route, is answered 413 there, and is recorded with that
status. 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.
as a CORS preflight, or a request refused with 404 for a path no route has or
405 for a method its route does not take. Run on its own, the backend also
answers 413 before the route to a request declaring a body length over the 1 MiB
limit, which is not recorded. A body over the limit sent without a declared
length reaches the route and is recorded with the status the route answers,
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
client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a
@@ -435,7 +491,10 @@ address nginx passes on and no other. The image:
each entry with `netwatch-server check-cidr`, which parses it as the backend
parses its own `TRUSTED_PROXIES`
- Sends access logs to stdout
- Caches static assets with immutable headers
- Sends the page with `Cache-Control: no-cache`, so a browser that keeps a copy
checks with the server that it is current before using it again; only the
files under `/assets/`, whose names change whenever their contents do, are
cached as immutable
- Sends the security headers `REPO_POLICIES.md` requires on every response, as
`configs/security-headers.conf` sets them, in place of the backend's own
- Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data`
+196 -86
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-04
last_modified: 2026-10-18
---
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
assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the
installed node if present; otherwise it installs a PINNED node version via
nvm, first installing nvm itself if missing — from a hash-verified GitHub
release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
installed node only when its major version is the pinned one; otherwise (node
missing, or another major, such as the node 24 the CI runner image ships) it
installs the PINNED node version via nvm and installs yarn and the packages
under it. nvm itself is installed first if missing, from a hash-verified
GitHub release archive (never `curl | sh`), with bash installed as an explicit
prerequisite since nvm requires bash. Only the major version is compared
because the `Dockerfile` stages start from a node image whose exact version is
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
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`script/bootstrap`'s own install step does. A runner carrying nothing but
docker and git then gets through `script/check`. Four further scripts are our
own extensions to the standard: `script/check` runs `script/test`,
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
containerised gates have passed. **The bootstrap alone is not enough**: when
`script/bootstrap` installs node and yarn under nvm it leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs
under another node. The host entrypoints that need yarn — `script/fmt` and
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`,
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the
pinned node. A runner carrying nothing but docker and git then gets through
`script/check`. Four further scripts are our own extensions to the standard:
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`;
`script/precommit` is what the git pre-commit hook runs, and it calls
`script/check`; `script/install-precommit` installs the git pre-commit hook
(the `make hooks` target shims to it); and `script/projectname` (literally
that filename) simply outputs the project's name. Scripts that need the name
call `script/projectname` — e.g. `script/docker` assembles its image tag from
it — so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
@@ -118,8 +123,10 @@ style conventions are in separate documents:
and nothing else:
```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--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
@@ -129,10 +136,15 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
**The gate builds write no image.** With `--output type=cacheonly` the phase
runs and a failing step fails the build, but the result is not exported.
Nothing uses those images, and writing one out is slow: a Go test phase's
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`,
`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
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
org, in both directions. A container has its own `TMPDIR`, so the lock is
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
a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **Any build that runs checks is given a new `CACHEBUST` value.** Docker
invalidates a `COPY` layer only when the copied content changes, so on an
unchanged tree the check `RUN` is served from cache, nothing executes, and the
build still exits 0. Every `docker build` in `script/` therefore passes
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process
id, so no two builds share a value, not even the three in one
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and
`script/docker` are the four, and there is no fifth — `script/check` runs the
two gate phases and `script/fmt-check`, and builds no image of its own. In
every stage of the `Dockerfile`, `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. 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 lint phase is based on the `golangci/golangci-lint` image (pinned by
@@ -171,40 +203,56 @@ style conventions are in separate documents:
`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
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
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
# 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
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
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 ---"; \
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
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# are what make BuildKit build them first, so nothing after them can
# run unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
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 . .
# 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
# build: git is missing or could not read the checkout.
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 \
case "$VERSION" in ""|dev|unknown) \
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
FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/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
same two copies. A plain `docker build .` builds the last stage's chain
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
(e.g. a web frontend compiled in a separate stage), the lint phase must
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
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
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
FROM golangci/golangci-lint@sha256:... AS lint
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& 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
@@ -278,20 +356,39 @@ style conventions are in separate documents:
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
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
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script bootstraps, runs the gate phases, and then builds the image, so a
successful run means every check passed; a bare `docker build .` does not
runs `script/cibuild` on push, and checks out the repo as its only other step,
with `persist-credentials: false`: `script/cibuild` needs no token, and
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
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
image build gets a `CACHEBUST` value of its own and so runs the gate phases a
second time. That is the price of the rule above, and it is worth paying: the
image that ships is built from a run of its own gates rather than from a cache
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the
shared runner after 20 minutes. That allows for the three Docker builds
described above (the test phase, the lint phase, then the image), each held to
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow
limited to `main` by a `branches` list under `on: push` cannot be checked by
review: to try a change to it, add the feature branch to that list and push,
then remove the branch from the list again before merging. Keep any job in it
that publishes behind `if: github.ref_name == 'main'`, so the run from the
feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -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
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
without running them. The directory lasts from one run to the next on a
developer's machine, where this target runs, and in the `test` phase of the
`Dockerfile`, which mounts it to keep compiled packages between check
builds, so the phase's `go test` runs carry `-count=1` too. A new
`CACHEBUST` value (above) is what makes that step run on an unchanged tree.
Python example:
@@ -384,9 +480,12 @@ style conventions are in separate documents:
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
setting up a new repo. These patterns are written to `.gitignore`'s own
`node_modules/`, and the repo's own build outputs. Fetch the standard
`.gitignore` from
`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
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:
```sh
# Own line: a failing command substitution inside an argument does not
# trip `set -e`, so the inline form degrades to an empty constant.
# The version and the tag each get their own line: a failing command
# substitution inside an argument does not trip `set -e`, so the inline
# form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
tag="$(script/projectname)"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
-t "$tag" .
```
`--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
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
checkout action clones shallow and fetches no tags, so the canonical
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
@@ -520,6 +621,11 @@ style conventions are in separate documents:
function defined and never invoked has the same exit status and the same
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`.
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.
- 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
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
+105 -9
View File
@@ -21,6 +21,106 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps
- 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: 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`
([#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.
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,
and show an empty page. A path that `location /` answers with the page through
its `try_files` fallback gets the same header. `location /` now includes the
security headers again, as `/assets/` does, since its own `add_header` drops
the server's. `/assets/` keeps its immutable caching.
`make 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
- 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
([#149](https://git.eeqj.de/sneak/netwatch/issues/149)). The clocks name the
local time zone through `Intl.DateTimeFormat`'s `shortGeneric` option, which
Chrome before 95, Firefox before 91 and Safari before 15.4 reject with an
error; it came before the first round started, so such a browser never checked
a target. The clocks now start last, after the rounds, and an error formatting
them leaves both blank. The footer's color legend reads `>=500ms` for red,
where it read `>500ms`, so 500 ms, which is red, is in one of its colors
([#152](https://git.eeqj.de/sneak/netwatch/issues/152)). Unit tests check the
clocks with a working `Intl.DateTimeFormat` and with one that rejects
`shortGeneric`, and draw the page and check the legend against every entry of
`CONFIG.latencyColors`
- 2026-10-18: `netwatch-server prepare-data-dir` no longer changes the owner of
a file outside `/data` through a hard link in the volume
([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A file with a second
name (hard link) keeps its owner, and the start logs its path. Owners are now
set on the opened directory, file or link, never by name, so a hard link
swapped in after the check is caught too; a symbolic link is opened without
following it and given to the netwatch user itself, so the server can still
follow one in a sticky directory that anyone can write to. Devices, named
pipes and sockets keep their owner. Setting the owner on an open file that is
not a directory is built only for Linux: `prepare-data-dir` runs only in the
Linux image, and macOS, where the backend is also built for development, has
neither `O_PATH` nor `AT_EMPTY_PATH`. On any other system the package still
builds, and `prepare-data-dir` fails at the first file that is not a
directory. The test that runs as root hard-links a file, a symbolic link and a
named pipe from outside the volume into it and finds their owners unchanged
- 2026-10-18: from a trusted proxy, the backend takes the client address from
the right-most `X-Forwarded-For` entry that is not in `TRUSTED_PROXIES`
([#144](https://git.eeqj.de/sneak/netwatch/issues/144)). It took the left-most
@@ -32,9 +132,11 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
used. `X-Real-IP` is read only without `X-Forwarded-For`, and every
`X-Forwarded-For` header line is read, not only the first. The image's nginx,
and the `TRUSTED_PROXIES=127.0.0.1/32` it starts the backend with, are
unchanged. Unit tests resolve the address from chains ending in an untrusted
address, in trusted ones, of only trusted ones, with ports, with an entry that
is not an address, and split over two header lines
unchanged; the comment in `configs/nginx.conf` that said the backend takes the
first entry now says it takes the one address nginx passes on. Unit tests
resolve the address from chains ending in an untrusted address, in trusted
ones, of only trusted ones, with ports, with an entry that is not an address,
and split over two header lines
- 2026-10-18: after an interval change, the summary's Reachable slot reads
`--/--` in white, as at page load, until the second round since the change
ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
@@ -545,9 +647,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)
- Export of the latency history (CSV or JSON)
- 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)
+7 -4
View File
@@ -66,10 +66,13 @@ done > /etc/nginx/trusted-proxies.conf
# 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
# uid. Here, as root, netwatch-server prepare-data-dir creates DATA_DIR
# and gives /data and everything in it to the netwatch user, so the
# host directory needs no preparing. It stops the start, naming
# DATA_DIR, unless DATA_DIR is /data or a path below it, and it acts on
# nothing outside /data, whatever symbolic links it meets there.
# and gives /data and the directories, regular files and symbolic links
# in it to the netwatch user, so the host directory needs no preparing.
# A file with a second name (hard link) keeps its owner, as that name
# could be outside /data, and its path is logged. It stops the start,
# 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
# meets there.
export DATA_DIR="${DATA_DIR:-/data/reports}"
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
+8 -1
View File
@@ -30,8 +30,13 @@ server {
access_log /dev/stdout combined;
error_log /dev/stderr warn;
# With no-cache, a browser never uses a copy of the page that names
# assets a newer build no longer has without first checking with the
# server that it is current.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control no-cache;
include /etc/nginx/security-headers.conf;
}
# Cache static assets aggressively
@@ -46,7 +51,9 @@ server {
# 127.0.0.1:8081. These headers go with every request passed to it.
# X-Forwarded-For carries only the client address, as resolved by
# the real IP settings above, and not the chain the request came
# with: the backend takes the first entry, which a client can write.
# with. bin/entrypoint.sh starts the backend with
# TRUSTED_PROXIES=127.0.0.1/32, the address nginx connects from, so
# the backend takes that one entry as the client address.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
+1 -1
View File
@@ -17,6 +17,7 @@ require (
github.com/slok/go-http-metrics v0.13.0
github.com/spf13/viper v1.21.0
go.uber.org/fx v1.24.0
golang.org/x/sys v0.47.0
)
require (
@@ -41,7 +42,6 @@ require (
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+32 -23
View File
@@ -32,6 +32,9 @@ const (
forwardedIP = "203.0.113.7"
// realIP is the client address presented via X-Real-IP.
realIP = "203.0.113.9"
// appendedIP is the X-Forwarded-For entry a trusted proxy appended
// after the entries the client wrote.
appendedIP = "198.51.100.7"
)
func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
@@ -90,8 +93,8 @@ func clientIPCases() []clientIPCase {
{
name: "trusted proxy uses right-most of chain",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 198.51.100.7",
want: "198.51.100.7",
xff: "203.0.113.9, " + appendedIP,
want: appendedIP,
},
{
name: "trusted proxy entries are skipped from the right",
@@ -105,24 +108,6 @@ func clientIPCases() []clientIPCase {
xff: "10.0.0.5, 10.1.2.3",
want: loopbackIP,
},
{
name: "entry with a port is read as its address",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 198.51.100.7:5000",
want: "198.51.100.7",
},
{
name: "ipv6 entry with a port is read as its address",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, [2001:db8::1]:443",
want: "2001:db8::1",
},
{
name: "trusted proxy entry with a port is skipped",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 10.0.0.5:5000",
want: "203.0.113.9",
},
{
name: "entry that is not an address uses peer",
remoteAddr: loopbackPeer,
@@ -202,12 +187,36 @@ func TestClientIPReadsEveryForwardedForLine(t *testing.T) {
header := http.Header{}
header.Add("X-Forwarded-For", "203.0.113.9")
header.Add("X-Forwarded-For", "198.51.100.7")
header.Add("X-Forwarded-For", appendedIP)
got := middleware.ClientIP(loopbackPeer, header,
mustPrefixes(t, "127.0.0.1/32"))
if got != "198.51.100.7" {
t.Errorf("ClientIP() = %q, want %q", got, "198.51.100.7")
if got != appendedIP {
t.Errorf("ClientIP() = %q, want %q", got, appendedIP)
}
}
// TestClientIPReadsAddressWithPort checks that an X-Forwarded-For entry
// written as an address with a port is read as that address, both for
// the trust check and as the result.
func TestClientIPReadsAddressWithPort(t *testing.T) {
t.Parallel()
trusted := mustPrefixes(t, "127.0.0.1/32", "10.0.0.0/8")
for xff, want := range map[string]string{
"203.0.113.9, " + appendedIP + ":5000": appendedIP,
"203.0.113.9, [2001:db8::1]:443": "2001:db8::1",
"203.0.113.9, 10.0.0.5:5000": "203.0.113.9",
} {
header := http.Header{}
header.Set("X-Forwarded-For", xff)
got := middleware.ClientIP(loopbackPeer, header, trusted)
if got != want {
t.Errorf("X-Forwarded-For %q: ClientIP() = %q, want %q",
xff, got, want)
}
}
}
+62 -32
View File
@@ -18,9 +18,15 @@ var ErrDataDirOutsideVolume = errors.New(
// PrepareDataDir gets dir, the server's DATA_DIR, ready for owner, the
// user the server runs as, so that a host directory mounted at volume,
// /data in the image, needs no preparing: it creates dir, gives volume
// and everything in it to owner, and gives volume and dir the mode the
// server gives a directory it creates. dir must be volume or a path
// below it, with no '.', '..', empty part or '/' at the end.
// and the directories, regular files and symbolic links in it to
// owner, and gives volume and dir the mode the server gives a directory
// it creates. It leaves a file with a second name (hard link) as it
// is, as that name could be outside volume, and logs its path; it
// leaves a device, named pipe or socket as it is too (see chownFile).
// dir must be volume or a path below it, with no '.', '..', empty part
// or '/' at the end. On any system but Linux, the system of the image, it
// fails at the first file in volume that is not a directory (see
// chownFile).
//
// bin/entrypoint.sh runs this as root, which would follow a symbolic
// link anywhere, so every step goes through an os.Root opened on
@@ -32,10 +38,9 @@ var ErrDataDirOutsideVolume = errors.New(
// open, never following a link at the name it creates, and follows a
// link on the path only as the os.Root allows, so a relative link
// inside volume can lead it to create directories elsewhere inside
// volume. Lchown never changes what a link points to, and the modes
// are set on directories already opened (see chmodDir), so the most
// that process can do is make a step fail or wait, or act on
// something else inside volume.
// volume. The owners and modes are set on files already opened (see
// chownAll and chmodDir), so the most that process can do is make a
// step fail or wait, or act on something else inside volume.
func PrepareDataDir(volume, dir string, owner *user.User) error {
// rel is dir as a path from volume; IsLocal is false for one that
// leads out of it.
@@ -66,7 +71,7 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
return err
}
err = lchownAll(root, ".", uid, gid)
err = chownAll(root, ".", uid, gid)
if err != nil {
return err
}
@@ -79,28 +84,23 @@ func PrepareDataDir(volume, dir string, owner *user.User) error {
return chmodDir(root, rel)
}
// lchownAll gives name, a directory inside root, and everything in it
// to uid and gid. It reads each directory opened through root, not
// through root.FS(), which refuses a name that is not valid UTF-8, and
// calls Lchown on every entry, which gives a symbolic link itself to
// them, not what it points to. It goes into an entry only when the
// read found a directory there, so it follows no link it finds; one
// swapped in for that directory afterwards is followed only as the
// os.Root allows.
func lchownAll(root *os.Root, name string, uid, gid int) error {
err := root.Lchown(name, uid, gid)
if err != nil {
return err
}
dir, err := root.Open(name)
if err != nil {
return err
}
entries, err := dir.ReadDir(-1)
_ = dir.Close()
// chownAll gives name, a directory inside root, and the directories,
// regular files and symbolic links in it to uid and gid, but not the
// files chownFile leaves. It opens each through root and gives the
// open file to them, as chmodDir sets a mode on the open directory.
// Root.Lchown would not hold: it acts on whatever is at the name by
// then, and a hard link swapped in there shares its owner with a file
// that could be outside root. A symbolic link is given to them itself,
// never what it points to, because in a directory that anyone can
// write to and that has the sticky bit set, as /tmp has, the kernel
// follows a link only for the link's owner or when the link and the
// directory have the same owner. chownAll goes into an entry only when
// the read found a directory there, so it follows no link it finds;
// one swapped in afterwards for a directory is followed only as the
// os.Root allows, and one swapped in for any other file is not
// followed at all (see chownFile).
func chownAll(root *os.Root, name string, uid, gid int) error {
entries, err := chownDir(root, name, uid, gid)
if err != nil {
return err
}
@@ -108,9 +108,9 @@ func lchownAll(root *os.Root, name string, uid, gid int) error {
for _, entry := range entries {
entryName := filepath.Join(name, entry.Name())
if entry.IsDir() {
err = lchownAll(root, entryName, uid, gid)
err = chownAll(root, entryName, uid, gid)
} else {
err = root.Lchown(entryName, uid, gid)
err = chownFile(root, entryName, uid, gid)
}
if err != nil {
@@ -121,6 +121,36 @@ func lchownAll(root *os.Root, name string, uid, gid int) error {
return nil
}
// chownDir gives name, a directory inside root, to uid and gid, and
// returns what it holds. Like chmodDir, it opens name through root and
// refuses anything but a directory, which has no second name (hard
// link). It reads the open directory, not root.FS(), which refuses a
// name that is not valid UTF-8.
func chownDir(root *os.Root, name string, uid, gid int) ([]fs.DirEntry, error) {
dir, err := root.Open(name)
if err != nil {
return nil, err
}
defer func() { _ = dir.Close() }()
info, err := dir.Stat()
if err != nil {
return nil, err
}
if !info.IsDir() {
return nil, &fs.PathError{Op: "chown", Path: name, Err: syscall.ENOTDIR}
}
err = dir.Chown(uid, gid)
if err != nil {
return nil, err
}
return dir.ReadDir(-1)
}
// chmodDir gives name, a directory inside root, the mode the server
// gives a directory it creates. Root.Chmod would not hold: on Linux it
// checks that name is not a symbolic link, then sets the mode by name,
+65
View File
@@ -0,0 +1,65 @@
//go:build linux
package reportbuf
import (
"io/fs"
"log/slog"
"os"
"syscall"
"golang.org/x/sys/unix"
)
// chownFile gives name, a file inside root that is not a directory, to
// uid and gid if it is a regular file or symbolic link with one name.
// It leaves, and logs, a file of any kind with a second name (hard
// link): all the names of a file share its owner, and the other could
// be outside root. It leaves any other file with one name, such as a
// device, named pipe or socket: the server needs to own none of them.
// It opens name through root with O_PATH, which reads nothing from the
// file, so opening a device or named pipe neither waits nor acts on
// it; os.Root adds O_NOFOLLOW, and the two together open a symbolic
// link itself, never what it points to. It reads the kind of file and
// its number of names from the open file and gives that file to them,
// so a hard link swapped in at name is caught too.
//
// This file is built only on Linux: prepare-data-dir runs only in the
// Linux image, and macOS, where the backend is also built for
// development, has neither O_PATH nor AT_EMPTY_PATH. On any other
// system chownFile returns an error (see datadir_other.go).
func chownFile(root *os.Root, name string, uid, gid int) error {
file, err := root.OpenFile(name, unix.O_PATH, 0)
if err != nil {
return err
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok || stat.Nlink > 1 {
slog.Warn("owner not changed: file has a second name (hard link)",
"path", file.Name())
return nil
}
if !info.Mode().IsRegular() && info.Mode().Type() != fs.ModeSymlink {
return nil
}
// File.Chown would fail: fchown refuses a file opened with O_PATH.
// Fchownat with an empty name and AT_EMPTY_PATH acts on the open
// file instead.
err = unix.Fchownat(int(file.Fd()), "", uid, gid, unix.AT_EMPTY_PATH)
if err != nil {
return &fs.PathError{Op: "chown", Path: file.Name(), Err: err}
}
return nil
}
+21
View File
@@ -0,0 +1,21 @@
//go:build !linux
package reportbuf
import (
"errors"
"io/fs"
"os"
)
// chownFile returns an error on every system but Linux. Opening a file
// that is not a directory without reading it or following it, and
// giving it to a user on the open file, never by name, is built only
// for Linux (see datadir_linux.go): prepare-data-dir runs only in the
// Linux image, and macOS, where the backend is also built for
// development, has neither O_PATH nor AT_EMPTY_PATH. This file lets
// the package build elsewhere, such as on macOS, where a developer
// runs make build and make run.
func chownFile(_ *os.Root, name string, _, _ int) error {
return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported}
}
+40 -8
View File
@@ -254,10 +254,13 @@ func TestPrepareDataDirTakesDirectoryNamedInLatin1(t *testing.T) {
requireOwner(t, filepath.Join(latin1, "f"), uint32(uid), uint32(gid))
}
// TestPrepareDataDirGivesVolumeToOwner gives everything in the volume
// to a uid and gid that own nothing, which only root can do. A
// symbolic link in the volume to a directory outside it is given to
// them itself; what it points to is left as it was.
// TestPrepareDataDirGivesVolumeToOwner gives the directories, regular
// files and symbolic links in the volume to a uid and gid that own
// nothing, which only root can do. A symbolic link in the volume to a
// directory outside it is given to them itself; what it points to is
// left as it was. A hard link to a file outside the volume, one to a
// symbolic link outside it and one to a named pipe outside it are left
// as they were, and so is a named pipe in the volume.
func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
t.Parallel()
@@ -274,6 +277,16 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
t.Fatal(err)
}
err = os.Symlink("f", filepath.Join(outside, "link"))
if err != nil {
t.Fatal(err)
}
err = syscall.Mkfifo(filepath.Join(outside, "fifo"), 0o600)
if err != nil {
t.Fatal(err)
}
err = os.Mkdir(old, 0o700)
if err != nil {
t.Fatal(err)
@@ -289,6 +302,21 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
t.Fatal(err)
}
err = syscall.Mkfifo(filepath.Join(old, "fifo"), 0o600)
if err != nil {
t.Fatal(err)
}
// os.Link does not follow a symbolic link: it gives the link itself
// a second name.
for _, name := range []string{"f", "link", "fifo"} {
err = os.Link(filepath.Join(outside, name),
filepath.Join(old, "hardlink-to-"+name))
if err != nil {
t.Fatal(err)
}
}
err = reportbuf.PrepareDataDir(volume, filepath.Join(volume, reports),
&user.User{Uid: "4242", Gid: "4343"})
if err != nil {
@@ -296,12 +324,16 @@ func TestPrepareDataDirGivesVolumeToOwner(t *testing.T) {
}
for _, path := range []string{
volume, filepath.Join(volume, reports), old,
filepath.Join(old, "f"), filepath.Join(old, "link"),
volume, filepath.Join(volume, reports), old, filepath.Join(old, "f"),
filepath.Join(old, "link"),
} {
requireOwner(t, path, 4242, 4343)
}
requireOwner(t, outside, 0, 0)
requireOwner(t, filepath.Join(outside, "f"), 0, 0)
for _, path := range []string{
outside, filepath.Join(outside, "f"), filepath.Join(outside, "link"),
filepath.Join(outside, "fifo"), filepath.Join(old, "fifo"),
} {
requireOwner(t, path, 0, 0)
}
}
+77 -47
View File
@@ -1,6 +1,9 @@
// Package reportbuf accumulates telemetry reports in memory
// and periodically flushes them to zstd-compressed JSONL files,
// deleting the oldest files to keep them under a size cap.
// deleting the oldest files to keep them under a size cap. Before the
// server starts, PrepareDataDir gets the directory those files are
// kept in, DATA_DIR, ready for the user the server runs as: it creates
// it and gives it and the volume it is on to that user.
package reportbuf
import (
@@ -13,7 +16,6 @@ import (
"log/slog"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"sync/atomic"
@@ -70,11 +72,9 @@ type Buffer struct {
// except in tests that hold the write open or make it fail.
fileCreated func(f *os.File)
// files are the report files that may be deleted to make room,
// in name order, which is oldest first: those in dataDir at
// start, and each one this buffer writes, put in at its place by
// name once it is complete, even when an older file's write
// completes after a newer one's. A file still being written is
// not among them. filesBytes is their total size.
// oldest first: those in dataDir at start, in name order, then
// each one this buffer writes, once it is complete. A file still
// being written is not among them. filesBytes is their total size.
files []reportFile
filesBytes int64
log *slog.Logger
@@ -85,8 +85,12 @@ type Buffer struct {
now func() time.Time
// seq numbers the report files, so that two named in the same
// millisecond still get different names.
seq atomic.Uint64
seq atomic.Int64
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
// of the report files in dataDir, plus the reports waiting to
// be written to one at their uncompressed size.
@@ -96,6 +100,11 @@ type Buffer struct {
// refuses reports, and the reports of that write wait in buf for
// the next flush.
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,
// and each one Append starts at flushSizeThreshold. OnStop waits
// for them before the final flush.
@@ -124,16 +133,33 @@ func New(
lc.Append(fx.Hook{
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)
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
// the first to be deleted to make room.
files, err := reportFiles(b.dataDir)
if err != nil {
return err
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
}
b.mu.Lock()
@@ -187,8 +213,9 @@ func New(
// write of a report file failed, until a write succeeds again. If the
// line would take usedBytes past maxBytes, the oldest report files are
// deleted to make room; it stores nothing and returns ErrFull if that
// cannot make room. If the buffer reaches the size threshold, it is
// drained and written to disk asynchronously.
// cannot make room. If the buffer reaches the size threshold, it starts
// a write of the reports in the background (see flushAtThreshold),
// unless one it started is still waiting.
func (b *Buffer) Append(v any) error {
line, err := json.Marshal(v)
if err != nil {
@@ -218,18 +245,9 @@ func (b *Buffer) Append(v any) error {
b.buf.Write(line)
b.buf.WriteByte('\n')
if b.buf.Len() >= flushSizeThreshold {
data := b.drainBuf()
b.mu.Unlock()
b.writers.Go(func() {
writeErr := b.writeFile(data)
if writeErr != nil {
b.log.Error("flush reports failed", "error", writeErr)
}
})
return nil
if b.buf.Len() >= flushSizeThreshold &&
b.thresholdWriteWaiting.CompareAndSwap(false, true) {
b.writers.Go(b.flushAtThreshold)
}
b.mu.Unlock()
@@ -287,21 +305,28 @@ func (b *Buffer) flushLoop() {
}
}
// flushLocked acquires the lock, drains the buffer, and
// writes the data to a compressed file.
// flushLocked waits for any write of a report file under way, then
// writes the reports waiting in the buffer to a new one.
func (b *Buffer) flushLocked() error {
b.mu.Lock()
b.writeMu.Lock()
defer b.writeMu.Unlock()
if b.buf.Len() == 0 {
b.mu.Unlock()
return b.writeFile()
}
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.
@@ -314,10 +339,22 @@ func (b *Buffer) drainBuf() []byte {
return data
}
// writeFile writes data, reports drained from the buffer, to a new
// timestamped zstd-compressed JSONL file in the data directory. If the
// write fails, it puts the reports back in the buffer.
func (b *Buffer) writeFile(data []byte) error {
// writeFile takes the reports waiting in the buffer, if any, and writes
// them to a new timestamped zstd-compressed JSONL file in the data
// directory. If the write fails, it puts the reports back in the
// 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
// after it tells apart files named in the same millisecond.
ts := b.now().UTC().Format("2006-01-02T15-04-05.000Z")
@@ -348,14 +385,7 @@ func (b *Buffer) writeFile(data []byte) error {
b.usedBytes -= int64(len(data))
b.usedBytes += size
b.filesBytes += 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})
b.files = append(b.files, reportFile{name: name, size: size})
return nil
}
+309 -107
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
// 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
@@ -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
// fail. It is still there, so it still takes room, and the next oldest
// is deleted in its place.
@@ -604,17 +722,16 @@ func TestFileDeletedByHandFreesRoom(t *testing.T) {
}
}
// TestFileBeingWrittenIsNeverDeleted holds the write of one report file
// open while a second write completes, then sends a report that needs
// room. Deleting either file would make it, and the one being written is
// the older, but only the complete one may be deleted. Once the first
// write is complete, its file is deleted when room is needed.
// TestFileBeingWrittenIsNeverDeleted holds the write of a report file
// open under a cap with room for two reports, then appends a second
// report and a third that needs room. The file being written is the
// only report file, and it is not deleted to make room, so the third
// report is refused. Once the write is complete, its file is deleted
// when room is needed.
func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
report := map[string]string{"id": "writing"}
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)))
buf := startBuffer(t)
@@ -639,36 +756,25 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
writing := <-created
// Only the first write is held; the second goes through, and
// 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
// Errorf, not Fatalf, until the write is released, so that a
// failure here does not leave it held.
err = buf.Append(report)
if err != nil {
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)
if err != nil {
t.Errorf("report that needs room: %v", err)
if !errors.Is(err, reportbuf.ErrFull) {
t.Errorf("report that needs room: error = %v, want ErrFull", err)
}
if !exists(t, writing) {
t.Error("report file deleted while it was being written")
}
if exists(t, complete) {
t.Error("complete report file kept, though room was needed")
}
// Only this write is held; the writes after it, the final one at
// stop included, go through.
buf.OnFileCreated(func(*os.File) {})
close(release)
@@ -677,9 +783,11 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
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)
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) {
@@ -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
// handler, as the page does. Once a write has failed, a report is
// refused with 500 until a write succeeds again; the reports taken
@@ -1088,6 +1272,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
// FlushSizeThreshold bytes, so that the last append starts writing them
// to a report file, and returns the lines appended.
@@ -1233,85 +1517,3 @@ func hasReportFile(t *testing.T, dir string) bool {
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")
}
}
+14 -9
View File
@@ -2,9 +2,12 @@
# 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
# the formatter on the host, which a pristine checkout cannot do.
# --no-cache for the same reason as script/docker: the gate phases the
# final stage depends on are RUN steps, and a cached one is a check that
# did not run.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -14,15 +17,17 @@ main() {
cd "$ROOT"
"$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
# The version and the tag each get their own line: a failing
# command substitution inside an argument does not trip `set -e`,
# so the inline form degrades silently to an empty constant. The
# 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)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
-t "$tag" .
}
main "$@"
+14 -8
View File
@@ -1,8 +1,12 @@
#!/bin/sh
# script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname.
# --no-cache because the gate phases the final stage depends on are RUN
# steps, and a cached one is a check that did not run.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -10,15 +14,17 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
# Own line: a failing command substitution inside an argument does
# not trip `set -e`, so the inline form degrades silently to an
# empty constant. The VERSION build argument takes precedence over
# the version a build stage derives from the .git in the context.
# The version and the tag each get their own line: a failing
# command substitution inside an argument does not trip `set -e`,
# so the inline form degrades silently to an empty constant. The
# 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)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
tag="$("$SCRIPT_DIR/projectname")"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \
-t "$("$SCRIPT_DIR/projectname")" .
-t "$tag" .
}
main "$@"
+28
View File
@@ -7,6 +7,8 @@
# headless Chrome at it over CDP, and asserts on computed layout at every
# viewport width derived from the app's own CSS. See
# test/viewport/README.md for what this covers and what it cannot.
# Before the browser starts, it checks that the page is served with
# Cache-Control: no-cache and Content-Security-Policy.
#
# Deliberately not part of script/check: it needs Docker and takes far
# longer than the 20s budget make test has to stay inside.
@@ -75,6 +77,32 @@ main() {
-v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \
"$SERVER_IMAGE" > /dev/null
# The page, and /no-such-page, which location / answers with the
# page through its try_files fallback, must go out with
# Cache-Control: no-cache. That add_header in its location drops the
# server's own unless the location includes the security headers
# again; only Content-Security-Policy is checked, as it comes from
# 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
if ! headers="$(docker exec "$SERVER" curl -s -o /dev/null -D - \
--retry 10 --retry-connrefused --retry-delay 1 \
"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' ||
! printf '%s\n' "$headers" | grep -qi '^Content-Security-Policy:'; then
echo "frontend-viewport-test: $path needs" \
"Cache-Control: no-cache and Content-Security-Policy; got:" >&2
printf '%s\n' "$headers" >&2
exit 1
fi
done
# The image's own entrypoint already exposes CDP on 9222 and passes
# --no-sandbox, so only extra flags belong here; re-specifying the
# debugging port collides with it and leaves the endpoint bound to
+10 -5
View File
@@ -5,9 +5,14 @@
# make its answer untrustworthy.
#
# 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
# that did not run. The tag makes each build replace the previous image
# instead of leaving a dangling one behind.
# --target names it. A new CACHEBUST value reruns every RUN step after
# the ARG CACHEBUST line, since a cached lint layer is a lint 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.
# --output type=cacheonly writes no image, since nothing uses one.
set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -15,9 +20,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target lint \
-t "$("$SCRIPT_DIR/projectname")-lint" .
--output type=cacheonly .
}
main "$@"
+9 -4
View File
@@ -2,8 +2,13 @@
# 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:
# --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
# run, and a tag so each build replaces the previous image.
# named, and a new CACHEBUST value because a cached test layer is a test
# 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
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -11,9 +16,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() {
cd "$ROOT"
docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test \
-t "$("$SCRIPT_DIR/projectname")-test" .
--output type=cacheonly .
}
main "$@"
+106 -11
View File
@@ -6,6 +6,7 @@ import { beforeEach, test } from "node:test";
import assert from "node:assert/strict";
import {
AppState,
buildUI,
CONFIG,
greyOutUI,
hostRowHTML,
@@ -17,6 +18,7 @@ import {
measureLatency,
Reporter,
tick,
updateClocks,
updateSummary,
} from "../../web/src/main.js";
@@ -28,8 +30,10 @@ import {
// selector until the next test starts. As on a page, writing its text
// replaces its markup; the status dot greyOutUI looks for in it is not
// there. Drawing a sparkline does nothing; it looks for the pixel ratio on
// window and finds none. In each test, console.error and console.info
// print nothing and keep what they are given.
// window and finds none. The commit the page's footer names, which
// vite.config.js fills in when it builds the page, is blank. In each test,
// console.error and console.info print nothing and keep what they are
// given.
const doNothing = () => {};
let elements;
beforeEach((t) => {
@@ -51,6 +55,8 @@ const canvasContext = {
globalThis.window = {};
globalThis.requestAnimationFrame = doNothing;
globalThis.cancelAnimationFrame = doNothing;
globalThis.__COMMIT_HASH__ = "";
globalThis.__COMMIT_FULL__ = "";
globalThis.document = {
getElementById: () => null,
createElement: () => ({}),
@@ -77,6 +83,11 @@ function statusText(state, host) {
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
// time, and replaces fetch with targets that each answer after
// answerAfter(url) milliseconds of that clock, or never when that is
@@ -196,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
// 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;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
@@ -214,9 +226,13 @@ test("at a 30000ms interval, a check still waiting when its round is given up do
roundChecks.abort();
assert.notEqual(await settled(round), "still waiting");
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;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
@@ -231,9 +247,13 @@ test("at a 30000ms interval, a check still waiting when the user pauses does not
t.mock.timers.tick(500);
assert.equal(await settled(round), "still waiting");
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;
const state = new AppState([
{ name: "Answering", url: "https://answering.test" },
@@ -245,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);
assert.equal(await settled(round), "still waiting");
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) => {
@@ -294,11 +318,6 @@ function stateWithOneTarget() {
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) => {
const { state, target } = stateWithOneTarget();
mockTargets(t, () => 23, target.url);
@@ -579,6 +598,32 @@ test("a check that timed out or found its target unreachable has a grey figure a
assert.equal(latencyHex(null), "#6b7280");
});
// buildUI draws the page into #app, here a plain object. Each entry in the
// footer's color legend is a colored dot and the latencies it stands for,
// with < and > written as &lt; and &gt;. A latency takes the color of the
// first entry in CONFIG.latencyColors whose limit it is below, so the
// legend has, for each of those entries in order, a dot of its color (a
// bg- class where CONFIG.latencyColors has a text- one) and the latencies
// below its limit, or, for the last, which has no limit, the latencies
// from the limit before it up. A gray dot for offline comes after them.
test("the footer's color legend has each entry in CONFIG.latencyColors, with its color and limit, then offline", (t) => {
const app = {};
t.mock.method(globalThis.document, "getElementById", () => app);
buildUI(new AppState([]));
const legend = [
...app.innerHTML.matchAll(/(bg-\w+-500) [^"]*"><\/span>(\S+)/g),
].map(([, dot, latencies]) => [dot, latencies]);
const colors = CONFIG.latencyColors;
const expected = colors.map((color, i) => [
color.className.replace("text-", "bg-"),
color.below === Infinity
? `&gt;=${colors[i - 1].below}ms`
: `&lt;${color.below}ms`,
]);
assert.deepEqual(legend, [...expected, ["bg-gray-500", "offline"]]);
});
// A target whose checks, in turn, answered after each of latencies ms, or,
// for null, found it unreachable.
function hostAfter(latencies) {
@@ -701,6 +746,56 @@ test("once every WAN target's check has failed, the summary's Reachable slot rea
assert.equal(elements["#summary-reachable"].className, "text-red-400");
});
// In the next two tests each of the header's clocks is a plain object, made
// the first time updateClocks looks it up and kept in elements under "#" and
// its id.
// The mocked clock reads 1970-01-01T00:00:00.000Z.
test("the header's clocks show the local time with its time zone's name, and the UTC time", (t) => {
t.mock.method(
globalThis.document,
"getElementById",
(id) => (elements[`#${id}`] ??= {}),
);
t.mock.timers.enable({ apis: ["Date"] });
updateClocks();
assert.match(
elements["#clock-local"].textContent,
/^Local: \d{4}-\d\d-\d\d \d\d:\d\d:\d\d \S/,
);
assert.equal(
elements["#clock-utc"].textContent,
"UTC: 1970-01-01 00:00:00 UTC",
);
});
// Here Intl.DateTimeFormat is as in Chrome before 95, Firefox before 91 or
// Safari before 15.4: it throws a RangeError when asked for the time zone's
// "shortGeneric" name.
test("in a browser that cannot name the time zone, updating the clocks throws nothing and leaves them blank", (t) => {
t.mock.method(
globalThis.document,
"getElementById",
(id) => (elements[`#${id}`] ??= {}),
);
const DateTimeFormat = Intl.DateTimeFormat;
t.mock.method(Intl, "DateTimeFormat", function (locales, options) {
if (options?.timeZoneName === "shortGeneric") {
throw new RangeError(
"Value shortGeneric out of range for Intl.DateTimeFormat options property timeZoneName",
);
}
return new DateTimeFormat(locales, options);
});
updateClocks();
assert.equal(elements["#clock-local"].textContent, "");
assert.equal(elements["#clock-utc"].textContent, "");
});
// An app state in which, of the WAN targets, the first timedOut timed out,
// the next unreachable were found unreachable, the next answered answered
// after latency ms, and the rest have not been checked yet.
+34 -20
View File
@@ -829,7 +829,7 @@ function wanRowsHTML(wanHosts) {
return rows.join("");
}
function buildUI(state) {
export function buildUI(state) {
const app = document.getElementById("app");
app.innerHTML = `
<div class="mx-auto px-[5%] py-8">
@@ -911,7 +911,7 @@ function buildUI(state) {
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span>&lt;100ms
<span class="inline-block w-3 h-3 rounded-full bg-yellow-500 mr-1 ml-3 align-middle"></span>&lt;200ms
<span class="inline-block w-3 h-3 rounded-full bg-orange-500 mr-1 ml-3 align-middle"></span>&lt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;500ms
<span class="inline-block w-3 h-3 rounded-full bg-red-500 mr-1 ml-3 align-middle"></span>&gt;=500ms
<span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p>
<p class="mt-2"><a href="https://git.eeqj.de/sneak/netwatch/commit/${__COMMIT_FULL__}" target="_blank" rel="noopener" class="text-gray-600 hover:text-gray-400">${__COMMIT_HASH__}</a></p>
@@ -1114,6 +1114,33 @@ function updateHealthBox(state) {
else if (health === "healthy") log.notice(`Health: ${labels[health]}`);
}
// Writes the local time, with its time zone's name, and the UTC time into
// the header's clocks. The clocks only decorate the page: in a browser
// whose Intl.DateTimeFormat rejects the "shortGeneric" time zone name
// (Chrome before 95, Firefox before 91, Safari before 15.4) they are left
// blank.
export function updateClocks() {
let local = "";
let utc = "";
try {
const now = new Date();
const zone = new Intl.DateTimeFormat("en-US", {
timeZoneName: "shortGeneric",
})
.formatToParts(now)
.find((p) => p.type === "timeZoneName").value;
const pad = (n) => String(n).padStart(2, "0");
local = `Local: ${now.toLocaleString("sv-SE", { hour12: false })} ${zone}`;
utc =
`UTC: ${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` +
`${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`;
} catch {
// Both clocks are left blank.
}
document.getElementById("clock-local").textContent = local;
document.getElementById("clock-utc").textContent = utc;
}
// --- Debug Log Renderer ------------------------------------------------------
// One line of the debug log panel, its message set as text.
@@ -1509,24 +1536,6 @@ async function init() {
sortAndRebuildWAN(state);
});
function updateClocks() {
const now = new Date();
const pad = (n) => String(n).padStart(2, "0");
const utc =
`${now.getUTCFullYear()}-${pad(now.getUTCMonth() + 1)}-${pad(now.getUTCDate())} ` +
`${pad(now.getUTCHours())}:${pad(now.getUTCMinutes())}:${pad(now.getUTCSeconds())} UTC`;
const local =
now.toLocaleString("sv-SE", { hour12: false }) +
" " +
new Intl.DateTimeFormat("en-US", { timeZoneName: "shortGeneric" })
.formatToParts(now)
.find((p) => p.type === "timeZoneName").value;
document.getElementById("clock-local").textContent = "Local: " + local;
document.getElementById("clock-utc").textContent = "UTC: " + utc;
}
updateClocks();
setInterval(updateClocks, 1000);
// Rounds never overlap: a round first gives up the last round's checks
// if they are still waiting, and the last round then records nothing
// more. At a steady interval they never are, as they time out at 80% of
@@ -1606,6 +1615,11 @@ async function init() {
window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), CONFIG.resizeDelay);
// The clocks start last, after the rounds: they only decorate the page,
// so nothing in them may stop the monitor from probing.
updateClocks();
setInterval(updateClocks, 1000);
}
// Bootstrap only when loaded as the page: a real DOM containing the #app