1 Commits
Author SHA1 Message Date
sneak 4d999fbc7e 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 needs Linux, the
image's system; elsewhere it returns an error. A failed owner change
names the file.

Model: opus-5-5
2026-10-18 22:20:21 +00:00
18 changed files with 223 additions and 853 deletions
+17 -39
View File
@@ -10,22 +10,11 @@
# phase covers the frontend as well, through a copy from a node stage. # phase covers the frontend as well, through a copy from a node stage.
# Inside them each tool is invoked directly, never through make or # Inside them each tool is invoked directly, never through make or
# script/, whose lint and test are themselves docker builds. # script/, whose lint and test are themselves docker builds.
#
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it, as does yarn install in the node stages. script/
# passes a new value on every build, which reruns every RUN step after
# it. Docker keeps the layers above it until the base image, their own
# lines or go.mod and go.sum change. The steps that compile mount Go's
# build cache, and golangci-lint run also mounts golangci-lint's; both
# survive from build to build.
# Frontend lint stage: eslint with the rules in eslint.config.js. The # Frontend lint stage: eslint with the rules in eslint.config.js. The
# lint phase below runs it. # lint phase below runs it.
# node:22-alpine as of 2026-02-22 # node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-lint
ARG CACHEBUST
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
@@ -40,11 +29,8 @@ FROM golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a924
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN golangci-lint run --config .golangci.yml ./...
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Nothing is wanted from frontend-lint; the copy is what makes this # Nothing is wanted from frontend-lint; the copy is what makes this
# phase run it. # phase run it.
COPY --from=frontend-lint /app/yarn.lock /dev/null COPY --from=frontend-lint /app/yarn.lock /dev/null
@@ -57,7 +43,6 @@ COPY --from=frontend-lint /app/yarn.lock /dev/null
# node would take a reporter option for one more file. # node would take a reporter option for one more file.
# node:22-alpine as of 2026-02-22 # node:22-alpine as of 2026-02-22
FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test FROM node@sha256:e4bf2a82ad0a4037d28035ae71529873c069b13eb0455466ae0bc13363826e34 AS frontend-test
ARG CACHEBUST
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
@@ -73,19 +58,19 @@ RUN NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || \
# ships and the alpine one does not. -timeout 90s is a backstop above # ships and the alpine one does not. -timeout 90s is a backstop above
# the 60-second cap on the suite. The rerun with -v only shows details: # the 60-second cap on the suite. The rerun with -v only shows details:
# the step fails however it ends, because the first run already failed. # the step fails however it ends, because the first run already failed.
# -count=1 because the mounted cache also holds test results, which go # Go's module and build caches are in memory (tmpfs) for the go test
# test would otherwise replay. # 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.
# golang:1.25.7-trixie, 2026-10-07 # golang:1.25.7-trixie, 2026-10-07
FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test FROM golang@sha256:2b174ffcf56c7ad0c47d30d2630693265639ddf2a5141149c2da34db921791b4 AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN --mount=type=tmpfs,target=/go/pkg/mod \
go test -count=1 -timeout 90s -race -cover ./... || \ --mount=type=tmpfs,target=/root/.cache/go-build \
go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Nothing is wanted from the frontend-test stage; the copy is what makes # Nothing is wanted from the frontend-test stage; the copy is what makes
# this phase run its tests. # this phase run its tests.
COPY --from=frontend-test /app/yarn.lock /dev/null COPY --from=frontend-test /app/yarn.lock /dev/null
@@ -98,28 +83,23 @@ COPY --from=frontend-test /app/yarn.lock /dev/null
FROM frontend-test AS frontend-build FROM frontend-test AS frontend-build
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null COPY --from=test /src/go.sum /dev/null
# vite.config.js reads the commit for the page's footer with git. git is # vite.config.js reads the commit for the page's footer with git.
# installed again on every image build, although it comes above
# ARG CACHEBUST, because this stage starts from frontend-test, whose
# steps run again on every build.
RUN apk add --no-cache git RUN apk add --no-cache git
ARG CACHEBUST
RUN yarn build RUN yarn build
# Backend build stage. Nothing is wanted from the two phases; the copies # Backend build stage. Nothing is wanted from the two phases; the copies
# are what make BuildKit build them first, so nothing after them can # are what make BuildKit build them first, so this stage cannot run
# run unless lint and test passed. # unless lint and test passed.
# golang:1.25-alpine (2026-02-27) # golang:1.25-alpine (2026-02-27)
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . . COPY . .
# The same go build as in script/build, which builds the binary on the # The same go build as in script/build, which builds the binary on the
@@ -132,8 +112,7 @@ COPY . .
# still empty, dev or unknown fails the build: git is missing or could # still empty, dev or unknown fails the build: git is missing or could
# not read the checkout. # not read the checkout.
ARG VERSION ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN version="${VERSION:-$(git describe --tags --always)}"; \
version="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \ if [ -e .git ]; then \
case "$version" in ""|dev|unknown) \ case "$version" in ""|dev|unknown) \
echo "version is '$version' although .git is present" >&2; \ echo "version is '$version' although .git is present" >&2; \
@@ -148,7 +127,6 @@ RUN --mount=type=cache,target=/root/.cache/go-build \
# and the stages it copies from, the two phases included. # and the stages it copies from, the two phases included.
# nginx:stable-alpine as of 2026-02-22 # nginx:stable-alpine as of 2026-02-22
FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab FROM nginx@sha256:15e96e59aa3b0aada3a121296e3bce117721f42d88f5f64217ef4b18f458c6ab
ARG CACHEBUST
# netwatch-server runs as this user, which owns the report directory. # netwatch-server runs as this user, which owns the report directory.
# nginx keeps the image's own arrangement: its main process runs as # nginx keeps the image's own arrangement: its main process runs as
+23 -87
View File
@@ -58,11 +58,11 @@ either one is broken. We provide:
defaults (see [Settings](#settings)) defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes - `script/clean` — remove what `script/build` writes
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/test` — build the `test` phase of `Dockerfile`, writing no image: the - `script/test` — build the `test` phase of `Dockerfile` without the cache: the
frontend's unit tests in its `frontend-test` stage, and the backend's Go tests frontend's unit tests in its `frontend-test` stage, and the backend's Go tests
with the race detector and coverage. The frontend's production build is left with the race detector and coverage. The frontend's production build is left
to the image build, in the `frontend-build` stage to the image build, in the `frontend-build` stage
- `script/lint` — build the `lint` phase of `Dockerfile`, writing no image: - `script/lint` — build the `lint` phase of `Dockerfile` without the cache:
eslint in its `frontend-lint` stage, and golangci-lint over the Go packages eslint in its `frontend-lint` stage, and golangci-lint over the Go packages
- `script/fmt` — format all files (writes): prettier over the JavaScript, CSS, - `script/fmt` — format all files (writes): prettier over the JavaScript, CSS,
HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the HTML and Markdown, then gofmt over `cmd/` and `internal/`. It runs on the
@@ -86,29 +86,15 @@ either one is broken. We provide:
- `script/frontend-fmt-check` — check prettier formatting (read-only) - `script/frontend-fmt-check` — check prettier formatting (read-only)
- `script/frontend-viewport-test` — responsive-layout verification of the built - `script/frontend-viewport-test` — responsive-layout verification of the built
frontend in a containerised headless Chrome (see frontend in a containerised headless Chrome (see
[test/viewport/README.md](test/viewport/README.md)). Before the browser [test/viewport/README.md](test/viewport/README.md)). Not part of
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/check`: it takes minutes.
- `script/docker` — build the image from `Dockerfile`, tagged `netwatch` via - `script/docker` — build the image from `Dockerfile` without the build cache,
`script/projectname` tagged `netwatch` via `script/projectname`
- `script/cibuild` — CI entrypoint: runs `script/bootstrap` and `script/check`, - `script/cibuild` — CI entrypoint: runs `script/bootstrap` and `script/check`,
then builds the image as `script/docker` does then builds the image as `script/docker` does, without the build cache
- `script/precommit` — run by the git pre-commit hook; runs `script/check` - `script/precommit` — run by the git pre-commit hook; runs `script/check`
- `script/install-precommit` — install the git pre-commit hook - `script/install-precommit` — install the git pre-commit hook
`script/test`, `script/lint`, `script/docker` and `script/cibuild` each pass a
new `CACHEBUST` value, so every step in `Dockerfile` that lints, tests or
compiles runs again even when nothing has changed. Docker keeps the system
packages and the Go modules from earlier builds until the base image, the step
that installs them, or `go.mod` and `go.sum` change. The `frontend-build` stage
is the exception: it installs git again on every image build, because it starts
from the `frontend-test` stage, whose steps run again on every build. Go's build
cache and golangci-lint's are kept as well, and each reuses a result only when
everything it was made from is unchanged. Both `go test` runs carry `-count=1`,
so no test result is reused. The yarn packages are installed again on every
build.
## Responsive layout ## Responsive layout
The narrow-viewport layout lives in the `max-width: 768px` media block in The narrow-viewport layout lives in the `max-width: 768px` media block in
@@ -167,26 +153,8 @@ report a delta, so only new samples are sent; the mark advances only on a
delivered report, and while paused nothing is sent. Changing the interval clears delivered report, and while paused nothing is sent. Changing the interval clears
each host's history on the page; the samples in it not yet reported still go in each host's history on the page; the samples in it not yet reported still go in
the next report. Delivery failure is quiet — one debug-log line per outage, the next report. Delivery failure is quiet — one debug-log line per outage,
never blocking probing. The report-building step is a pure function of host retried at the next interval, never blocking probing. The report-building step
state. 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 ### Backend
@@ -244,15 +212,8 @@ replaces the default; set and empty, the server trusts no proxy. Loopback is not
in the default, so a reverse proxy on the same host has to be named, as in in the default, so a reverse proxy on the same host has to be named, as in
`TRUSTED_PROXIES=127.0.0.1/32`. A request whose direct peer is outside this set `TRUSTED_PROXIES=127.0.0.1/32`. A request whose direct peer is outside this set
has its forwarded headers ignored, and the direct peer is logged and has its forwarded headers ignored, and the direct peer is logged and
rate-limited instead. From a direct peer inside the set, the client address is rate-limited instead. The container image does not use this default; see
the right-most `X-Forwarded-For` entry that is not in the set: each proxy [Deployment](#deployment).
appends the address it took the request from, so the entries further left were
written by the client and are not used. An entry written as an address with a
port, such as `198.51.100.7:5000` or `[2001:db8::1]:443`, is read as that
address. If the search from the right finds only addresses in the set, or meets
an entry that is not an address at all, such as `unknown`, the direct peer is
used. `X-Real-IP` is read only when `X-Forwarded-For` is missing or empty. The
container image does not use this default; see [Deployment](#deployment).
A variable set to a value the server cannot use, such as `PORT=abc`, A variable set to a value the server cannot use, such as `PORT=abc`,
`DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from `DEBUG=maybe` or a `BIND_ADDRESS` that is not an IP address, stops it from
@@ -272,19 +233,17 @@ Files are created with `O_EXCL` to prevent overwrites.
The reports of a failed write stay in memory, and the next flush, within a The reports of a failed write stay in memory, and the next flush, within a
minute, tries again to write them. From the failed write until a write succeeds, minute, tries again to write them. From the failed write until a write succeeds,
`POST /api/v1/reports` refuses every report with 500 and keeps nothing of it. `POST /api/v1/reports` refuses every report with 500 and keeps nothing of it, so
The page sends the refused samples again in its next report, as long as it still the page keeps its samples and sends them again at its next report. Reports
holds them: at the 1-second interval one refused report already loses some of already answered 200 are lost only if the server stops before a write succeeds;
those in the history (see [Reporting](#reporting)). Reports already answered 200 the final flush at stop tries them too, and if it fails, the process exits
are lost only if the server stops before a write succeeds; the final flush at non-zero.
stop tries them too, and if it fails, the process exits non-zero.
#### Report limits #### Report limits
`POST /api/v1/reports` takes reports from anyone who can reach it, without `POST /api/v1/reports` takes reports from anyone who can reach it, without
credentials, so it is bounded instead. Each refusal below answers with the same credentials, so it is bounded instead. Each refusal below answers with the same
`{"status":"error"}` body as any other error, except nginx's refusal of a body `{"status":"error"}` body as any other error.
over the limit in the image.
- **JSON only.** A report must be sent with `Content-Type: application/json`, - **JSON only.** A report must be sent with `Content-Type: application/json`,
parameters such as `charset` allowed, as the page sends it; any other is parameters such as `charset` allowed, as the page sends it; any other is
@@ -292,16 +251,6 @@ over the limit in the image.
send a `text/plain` POST, or a form, without a CORS preflight; refusing them send a `text/plain` POST, or a form, without a CORS preflight; refusing them
keeps any site from making its visitors' browsers store reports (see keeps any site from making its visitors' browsers store reports (see
[CORS](#cors)). [CORS](#cors)).
- **Body limit.** A report body may be at most 1 MiB, the backend's limit for
any request. Run on its own, the backend answers 413 before the route to a
request that declares a longer body. Without a declared length, the route
answers 413 only when the report it reads runs past 1 MiB; nothing after the
end of the report is read, so a longer body whose report ends sooner is
stored, and one that stops being valid JSON sooner is answered 400. In the
image, nginx refuses a body over 1 MiB first, with or without a declared
length, as its `client_max_body_size`, which `configs/nginx.conf` does not
set, is 1 MiB by default. nginx answers 413 with its own HTML error page, and
the backend never sees the request.
- **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may - **Rate limit.** Each client address, resolved through `TRUSTED_PROXIES`, may
send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with send `REPORTS_PER_MINUTE` reports a minute; past that it gets 429 with
`Retry-After: 60`. The minute slides: reports from the minute before still `Retry-After: 60`. The minute slides: reports from the minute before still
@@ -325,12 +274,6 @@ over the limit in the image.
how much of the newest reports is kept: the default of 1 GiB is small enough how much of the newest reports is kept: the default of 1 GiB is small enough
for any host; set it to the space you can give `DATA_DIR`. for any host; set it to the space you can give `DATA_DIR`.
At the defaults, one address may send 60 reports of up to 1 MiB a minute, and a
report made up to compress badly takes about as much room in the report files as
its request body, so in about 17 minutes one address can fill the 1 GiB cap with
its own reports, every report stored before them deleted to make room. Lowering
`REPORTS_PER_MINUTE` or raising `DATA_DIR_MAX_BYTES` makes that take longer.
#### CORS #### CORS
The page calls the API from the origin it is served from, so by default the The page calls the API from the origin it is served from, so by default the
@@ -352,16 +295,12 @@ the metrics record its duration and response size, labelled with its path,
method and status; they also count those requests in progress, and include Go's method and status; they also count those requests in progress, and include Go's
runtime and process metrics. No other request is recorded: not those to runtime and process metrics. No other request is recorded: not those to
`/metrics` itself, and not those answered before they reach either route, such `/metrics` itself, and not those answered before they reach either route, such
as a CORS preflight, or a request refused with 404 for a path no route has or as a CORS preflight, or a request refused with 404 for a path no route has, 405
405 for a method its route does not take. Run on its own, the backend also for a method its route does not take, or 413 for declaring a body length over
answers 413 before the route to a request declaring a body length over the 1 MiB the 1 MiB limit. A report whose body goes over the limit without declaring its
limit, which is not recorded. A body over the limit sent without a declared length reaches the route, is answered 413 there, and is recorded with that
length reaches the route and is recorded with the status the route answers, status. Clients can make up any number of paths and methods, and each would add
which is 413 only when the report it reads runs past the limit: nothing after labels to the metrics for as long as the server runs.
the end of the report is read. In the image neither reaches the backend: nginx
answers both with 413 itself (see [Report limits](#report-limits)), and nothing
of them is recorded. Clients can make up any number of paths and methods, and
each would add labels to the metrics for as long as the server runs.
`/metrics` is rate limited, so that its password cannot be guessed quickly: each `/metrics` is rate limited, so that its password cannot be guessed quickly: each
client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a client address, resolved through `TRUSTED_PROXIES`, may make 60 requests to it a
@@ -489,10 +428,7 @@ address nginx passes on and no other. The image:
each entry with `netwatch-server check-cidr`, which parses it as the backend each entry with `netwatch-server check-cidr`, which parses it as the backend
parses its own `TRUSTED_PROXIES` parses its own `TRUSTED_PROXIES`
- Sends access logs to stdout - Sends access logs to stdout
- Sends the page with `Cache-Control: no-cache`, so a browser that keeps a copy - Caches static assets with immutable headers
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 - 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 `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` - Stores reports in `DATA_DIR`, `/data/reports` by default, on the `/data`
+86 -196
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-10-18 last_modified: 2026-10-04
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -54,39 +54,34 @@ style conventions are in separate documents:
`cibuild`. `script/bootstrap` installs all dependencies idempotently and `cibuild`. `script/bootstrap` installs all dependencies idempotently and
assumes nothing is present: base tools come from nix, apt, brew, or apk assumes nothing is present: base tools come from nix, apt, brew, or apk
(detected in that order; apt runs noninteractive). For node it uses the (detected in that order; apt runs noninteractive). For node it uses the
installed node only when its major version is the pinned one; otherwise (node installed node if present; otherwise it installs a PINNED node version via
missing, or another major, such as the node 24 the CI runner image ships) it nvm, first installing nvm itself if missing — from a hash-verified GitHub
installs the PINNED node version via nvm and installs yarn and the packages release archive (never `curl | sh`), with bash installed as an explicit
under it. nvm itself is installed first if missing, from a hash-verified prerequisite since nvm requires bash. yarn is then pinned via
GitHub release archive (never `curl | sh`), with bash installed as an explicit `corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
prerequisite since nvm requires bash. Only the major version is compared always exact versions. `script/cibuild` runs the CI build: it changes to the
because the `Dockerfile` stages start from a node image whose exact version is repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
not the pin, and nvm cannot install a prebuilt node on alpine. yarn is pinned with the version; the Gitea workflow calls it. **`script/cibuild` runs
via `corepack prepare yarn@<version> --activate`. Never install "latest" or
"lts"; always exact versions. `script/cibuild` runs the CI build: it changes
to the repo root, runs `script/bootstrap`, runs `script/check`, and builds the
image with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs `script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**: when containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm it leaves neither on the `script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` either exits 127 or runs `PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
under another node. The host entrypoints that need yarn — `script/fmt` and entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
`script/fmt-check` — therefore use the same test as `script/bootstrap`: when source nvm for the pinned node version before invoking it, exactly as
the node on `PATH` has the pinned major version they run the `yarn` on `PATH`, `script/bootstrap`'s own install step does. A runner carrying nothing but
and otherwise they load nvm through `$HOME/.nvm/nvm.sh` and run yarn under the docker and git then gets through `script/check`. Four further scripts are our
pinned node. A runner carrying nothing but docker and git then gets through own extensions to the standard: `script/check` runs `script/test`,
`script/check`. Four further scripts are our own extensions to the standard: `script/lint` and `script/fmt-check`; `script/precommit` is what the git
`script/check` runs `script/test`, `script/lint` and `script/fmt-check`; pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
`script/precommit` is what the git pre-commit hook runs, and it calls installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/check`; `script/install-precommit` installs the git pre-commit hook `script/projectname` (literally that filename) simply outputs the project's
(the `make hooks` target shims to it); and `script/projectname` (literally name. Scripts that need the name call `script/projectname` — e.g.
that filename) simply outputs the project's name. Scripts that need the name `script/docker` assembles its image tag from it — so those scripts stay
call `script/projectname` — e.g. `script/docker` assembles its image tag from byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
it — so those scripts stay byte-identical across all repos. Repo-type-specific `go mod tidy` verification in Go repos) belong in `script/precommit`, not in
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in the hook itself. Model scripts are at
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README `https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the must document the provided scripts in an **Entrypoints** section (see the
README requirements below). README requirements below).
@@ -123,10 +118,8 @@ style conventions are in separate documents:
and nothing else: and nothing else:
```sh ```sh
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --no-cache --target lint -t "$(script/projectname)-lint" .
--target lint --output type=cacheonly . docker build --no-cache --target test -t "$(script/projectname)-test" .
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--target test --output type=cacheonly .
``` ```
**A stage that is not the last one in the file is built only when the final **A stage that is not the last one in the file is built only when the final
@@ -136,15 +129,10 @@ style conventions are in separate documents:
plain `docker build .` builds the last stage alone and exits 0 having linted plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing. and tested nothing.
**The gate builds write no image.** With `--output type=cacheonly` the phase **Every `docker build` in `script/` is tagged**, here and in
runs and a failing step fails the build, but the result is not exported. `script/cibuild` and `script/docker`. An untagged build leaves a dangling
Nothing uses those images, and writing one out is slow: a Go test phase's image behind on every invocation, on every developer host and every CI
image holds the toolchain. A build given neither `--output` nor `-t` writes runner; a tagged one replaces the previous image.
an untagged image and leaves it dangling, on every developer host and every
CI runner. `script/cibuild` and `script/docker` build the image that ships
and tag it, so each build replaces the previous image; each assigns the tag
on its own line before the build, so `set -e` stops it where
`script/projectname` fails.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`, Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are `eslint`, `prettier` — never through `make lint` or `script/test`, which are
@@ -159,42 +147,22 @@ style conventions are in separate documents:
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own `TMPDIR`, so the lock is org, in both directions. A container has its own cache, its own `TMPDIR` and
out of reach. The lint phase does keep golangci-lint's cache between check a digest-pinned binary, so neither is reachable.
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 given a new `CACHEBUST` value.** Docker - **Any build that runs checks is built with `--no-cache`.** Docker invalidates
invalidates a `COPY` layer only when the copied content changes, so on an a `COPY` layer only when the copied content changes, so on an unchanged tree
unchanged tree the check `RUN` is served from cache, nothing executes, and the the check `RUN` is served from cache, nothing executes, and the build still
build still exits 0. Every `docker build` in `script/` therefore passes exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`--build-arg CACHEBUST="$(date +%s)-$$"`, the time and the script's process `script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
id, so no two builds share a value, not even the three in one four, and there is no fifth — `script/check` runs the two gate phases and
`script/cibuild`. `script/lint`, `script/test`, `script/cibuild` and `script/fmt-check`, and builds no image of its own. A bare `docker build .` is
`script/docker` are the four, and there is no fifth — `script/check` runs the not evidence that anything ran: a sub-second build reporting success is a
two gate phases and `script/fmt-check`, and builds no image of its own. In cache hit, not a result. Never invalidate by pruning — `docker builder prune`
every stage of the `Dockerfile`, `ARG CACHEBUST` comes right after the steps and friends destroy a build cache shared with every other build on the host.
that install system packages and download Go's modules, or right after `FROM` When a check is added or changed, prove it works by planting a defect it must
in a stage with neither, and every step that lints, tests or compiles comes catch and watching the run fail on it, then revert the defect. A green run
after it. A step that installs anything else as well, such as a alone shows neither that the check ran nor that it covers what it should.
`script/bootstrap` that also installs node packages, comes after it too. A new
value reruns every `RUN` step after that line; `COPY` steps are compared by
content and need nothing. Docker keeps the layers above the line until the
base image, their own lines or the files they copy change. A stage without the
`ARG` line is served from cache on an unchanged tree, so a repository
re-vendors the scripts and adds the `ARG` lines in one commit. The scripts do
not pass `--no-cache`: it reruns every step too, but it also builds the layers
above the line again and empties every cache mount, so Go's build cache and
golangci-lint's, which the canonical Go `Dockerfile` below mounts so that they
survive between check builds, would start empty every time. A bare
`docker build .` is not evidence that anything ran: a sub-second build
reporting success is a cache hit, not a result. Never invalidate by pruning —
`docker builder prune` and friends destroy a build cache shared with every
other build on the host. When a check is added or changed, prove it works by
planting a defect it must catch and watching the run fail on it, then revert
the defect. A green run alone shows neither that the check ran nor that it
covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.** - **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by The lint phase is based on the `golangci/golangci-lint` image (pinned by
@@ -203,56 +171,40 @@ style conventions are in separate documents:
`Dockerfile`: `Dockerfile`:
```dockerfile ```dockerfile
# In every stage ARG CACHEBUST comes right after the steps that install
# system packages and download Go's modules, or right after FROM in a
# stage with neither, and every step that lints, tests or compiles
# comes after it. script/ passes a new value on every build, which
# reruns every RUN step after it. Docker keeps the layers above it
# until the base image, their own lines or go.mod and go.sum change.
# The steps that compile mount Go's build cache, and golangci-lint run
# also mounts golangci-lint's; both survive from build to build.
# Lint phase # Lint phase
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD # golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN golangci-lint run --config .golangci.yml ./...
--mount=type=cache,target=/root/.cache/golangci-lint \
golangci-lint run --config .golangci.yml ./...
# Test phase. -race needs cgo and so a C compiler, which the Debian Go # Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not. -count=1 because the mounted # image ships and the alpine one does not.
# cache also holds test results, which go test would otherwise replay.
# golang:1.x, YYYY-MM-DD # golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test FROM golang@sha256:... AS test
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY . . COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN go test -timeout 90s -race -cover ./... || \
go test -count=1 -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; } go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies # Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so nothing after them can # are what make BuildKit build them first, so this stage cannot run
# run unless lint and test passed. # unless lint and test passed.
# golang:1.x-alpine, YYYY-MM-DD # golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
ARG CACHEBUST
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
COPY . . COPY . .
# The VERSION build arg when one is given, otherwise # The VERSION build arg when one is given, otherwise
@@ -260,8 +212,7 @@ style conventions are in separate documents:
# .git present, a version that is still empty, dev or unknown fails the # .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout. # build: git is missing or could not read the checkout.
ARG VERSION ARG VERSION
RUN --mount=type=cache,target=/root/.cache/go-build \ RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \ if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \ case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \ echo "version is '$VERSION' although .git is present" >&2; \
@@ -274,7 +225,6 @@ style conventions are in separate documents:
# Runtime stage, and the last one # Runtime stage, and the last one
FROM alpine@sha256:... FROM alpine@sha256:...
ARG CACHEBUST
COPY --from=builder /app /usr/local/bin/app COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"] ENTRYPOINT ["app"]
``` ```
@@ -289,26 +239,6 @@ style conventions are in separate documents:
- Keep the runtime stage last, and if you add a stage after it, give it the - Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain same two copies. A plain `docker build .` builds the last stage's chain
and nothing else. and nothing else.
- Check builds keep these, and none of them can hand back a stale result:
- The layers above `ARG CACHEBUST`: the system packages and Go's
modules. Docker installs the packages again when the base image or the
install line changes, and downloads the modules again when `go.mod` or
`go.sum` changes. Go checks every module against `go.sum` as it
downloads it, and no later build can change a kept layer. The modules
are kept in a layer rather than a cache mount because a cache mount is
shared by every build on the host, and Go builds from whatever files
it holds without checking them again (only `go mod verify` does), so a
file changed there would go unnoticed.
- Go's build cache (`/root/.cache/go-build`, where Go keeps it for
root), mounted on the steps that compile: `golangci-lint run`, both
`go test` runs and `go build`. Go reuses a compiled package only when
all its inputs are unchanged. The cache also holds test results, which
is why both `go test` runs carry `-count=1`.
- golangci-lint's cache (`/root/.cache/golangci-lint`, where the pinned
image keeps it), mounted on `golangci-lint run`. golangci-lint reuses
a package's findings only when the package's files and those of every
package it imports, `go.mod`, the linters and their settings in
`.golangci.yml`, and the golangci-lint version are all unchanged.
- If the project uses `//go:embed` directives that reference build artifacts - If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must (e.g. a web frontend compiled in a separate stage), the lint phase must
create placeholder files so the embed directives resolve. Example: create placeholder files so the embed directives resolve. Example:
@@ -317,20 +247,12 @@ style conventions are in separate documents:
in the lint phase. The `golangci/golangci-lint` image is Debian-based and in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package (`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them. The test phase, lists in the same `RUN`, so the layer does not keep them:
on the Debian Go image, installs the libraries its tests need the same
way. Either install goes right after `FROM`, above `ARG CACHEBUST`, so
Docker keeps it until the base image or the install line changes:
```dockerfile ```dockerfile
FROM golangci/golangci-lint@sha256:... AS lint
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \ && apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
ARG CACHEBUST
``` ```
- `.dockerignore` lets `.git` into the build context. It keeps out every git - `.dockerignore` lets `.git` into the build context. It keeps out every git
@@ -356,39 +278,20 @@ style conventions are in separate documents:
`.git` and the version still comes out empty, `dev` or `unknown`. A plain `.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that `docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument. refuses an empty build argument drops that refusal and keeps the argument.
A checkout whose `.git` is a file (a linked worktree, or a repository
checked out as a submodule) is the exception: that file points to a git
directory outside the build context, so the build cannot read the version
and a plain `docker build .` fails; pass the version with
`--build-arg VERSION=...`, as `script/docker` and `script/cibuild` already
do.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that - Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step, runs `script/cibuild` on push, and checks out the repo as its only other step.
with `persist-credentials: false`: `script/cibuild` needs no token, and That script bootstraps, runs the gate phases, and then builds the image, so a
without it the checkout leaves the job's token in `.git/config` for every successful run means every check passed; a bare `docker build .` does not
later step. The checkout step also sets `fetch-depth: 0`, which fetches the
tags `git describe` needs: by default it clones shallow with no tags, and a
tagged repository's CI build would stamp a bare short commit id. The
workflow's `concurrency` block groups runs by workflow and branch
(`${{ github.workflow }}-${{ github.ref }}`) with `cancel-in-progress: true`,
so a new push cancels the older run on the same branch, queued or running, and
no other: runs for replaced commits do not hold up the shared runner.
`script/cibuild` bootstraps, runs the gate phases, and then builds the image,
so a successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The carry the same guarantee, because its gate phases may come from the cache. The
image build gets a `CACHEBUST` value of its own and so runs the gate phases a image build is uncached and so runs the gate phases a second time. That is the
second time. That is the price of the rule above, and it is worth paying: the price of the rule above, and it is worth paying: the image that ships is built
image that ships is built from a run of its own gates rather than from a cache from a run of its own gates rather than from a cache entry. A separate
entry. The `check` job sets `timeout-minutes: 20`, so a hung build frees the workflow limited to `main` by a `branches` list under `on: push` cannot be
shared runner after 20 minutes. That allows for the three Docker builds checked by review: to try a change to it, add the feature branch to that list
described above (the test phase, the lint phase, then the image), each held to and push, then remove the branch from the list again before merging. Keep any
the 5-minute Docker build limit below, plus the bootstrap. A separate workflow job in it that publishes behind `if: github.ref_name == 'main'`, so the run
limited to `main` by a `branches` list under `on: push` cannot be checked by from the feature branch publishes nothing.
review: to try a change to it, add the feature branch to that list and push,
then remove the branch from the list again before merging. Keep any job in it
that publishes behind `if: github.ref_name == 'main'`, so the run from the
feature branch publishes nothing.
- Use platform-standard formatters: `black` for Python, `prettier` for - Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -448,11 +351,12 @@ style conventions are in separate documents:
That cache is Go's own, separate from Docker's layer cache. Go stores a That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`, run again on unchanged code it prints that result, marked `(cached)`,
without running them. The directory lasts from one run to the next on a without running them. That matters on a developer's machine, where this
developer's machine, where this target runs, and in the `test` phase of the target runs and the directory lasts from one run to the next. The `test`
`Dockerfile`, which mounts it to keep compiled packages between check phase of the `Dockerfile` needs no `-count=1`: its base image holds no
builds, so the phase's `go test` runs carry `-count=1` too. A new result for this repo's tests and nothing before its `go test` step runs a
`CACHEBUST` value (above) is what makes that step run on an unchanged tree. test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example: Python example:
@@ -480,12 +384,9 @@ style conventions are in separate documents:
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`), - `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`), editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
`node_modules/`, and the repo's own build outputs. Fetch the standard language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
`.gitignore` from from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up setting up a new repo. These patterns are written to `.gitignore`'s own
a new repo. A repo's `.gitignore` is the standard file followed by the repo's
own entries, such as its binaries; a re-vendor replaces the standard part and
keeps those entries. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified. are not a `.dockerignore` and must not be transplanted into one unmodified.
@@ -533,15 +434,13 @@ style conventions are in separate documents:
byte-identically across repos: byte-identically across repos:
```sh ```sh
# The version and the tag each get their own line: a failing command # Own line: a failing command substitution inside an argument does not
# substitution inside an argument does not trip `set -e`, so the inline # trip `set -e`, so the inline form degrades to an empty constant.
# form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$(script/projectname)" docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$(script/projectname)" .
``` ```
`--always` makes an untagged repo yield an abbreviated commit hash rather `--always` makes an untagged repo yield an abbreviated commit hash rather
@@ -553,8 +452,8 @@ style conventions are in separate documents:
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so the canonical checkout action clones shallow and fetches no tags, so a repo that embeds a
`.gitea/workflows/check.yml` sets `fetch-depth: 0` on its checkout step. tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the - **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build patterns.** Plant files at the root _and_ at least two directories deep, build
@@ -621,11 +520,6 @@ style conventions are in separate documents:
function defined and never invoked has the same exit status and the same function defined and never invoked has the same exit status and the same
empty output as one that worked. empty output as one that worked.
Node is the exception to the whole-token comparison: the canonical
`script/bootstrap` compares only its major version, because the node 22
alpine image the `Dockerfile` stages start from is not the exact pin, and
nvm cannot install a prebuilt node on alpine.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`. Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to A Go tool a repo needs on the host is installed with `go install` pinned to
@@ -742,11 +636,7 @@ style conventions are in separate documents:
Never edit existing migrations after release. Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings: the standard file from settings.
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.editorconfig`, which sets
tabs for `Makefile` and Go files, followed by the repo's own sections, such as
one for another language it uses. A re-vendor replaces the standard part and
keeps those sections.
- Avoid putting files in the repo root unless necessary. Root should contain - Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`, only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
+12 -95
View File
@@ -21,78 +21,6 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps # Completed Steps
- 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 - 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 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 ([#143](https://git.eeqj.de/sneak/netwatch/issues/143)). A file with a second
@@ -101,29 +29,12 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
swapped in after the check is caught too; a symbolic link is opened without 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 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 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 pipes and sockets keep their owner. Opening a file without following it and
not a directory is built only for Linux: `prepare-data-dir` runs only in the setting the owner on the open file needs Linux, the system of the image; on
Linux image, and macOS, where the backend is also built for development, has any other system the package still builds, and `prepare-data-dir` fails at the
neither `O_PATH` nor `AT_EMPTY_PATH`. On any other system the package still first file that is not a directory. The test that runs as root hard-links a
builds, and `prepare-data-dir` fails at the first file that is not a file, a symbolic link and a named pipe from outside the volume into it and
directory. The test that runs as root hard-links a file, a symbolic link and a finds their owners unchanged
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
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 and the
request log records. An entry written as an address with a port is read as
that address. With only trusted addresses in the header, or an entry that is
not an address at all (such as `unknown`) met on the way, the direct peer is
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; 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 - 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 `--/--` 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 ends ([#140](https://git.eeqj.de/sneak/netwatch/issues/140)). It used to read
@@ -634,3 +545,9 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
- A configurable host list (an environment variable or a config file) - A configurable host list (an environment variable or a config file)
- Export of the latency history (CSV or JSON) - Export of the latency history (CSV or JSON)
- A notification when the health status changes to DEGRADED - A notification when the health status changes to DEGRADED
- `make test` within the 20-second target
([#118](https://git.eeqj.de/sneak/netwatch/issues/118)): every run compiles
the Go standard library and every dependency with the race detector from
nothing. Whether test builds may keep Go's compiled packages between runs
waits on sneak's ruling in
[prompts#113](https://git.eeqj.de/sneak/prompts/issues/113)
+1 -8
View File
@@ -30,13 +30,8 @@ server {
access_log /dev/stdout combined; access_log /dev/stdout combined;
error_log /dev/stderr warn; 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control no-cache;
include /etc/nginx/security-headers.conf;
} }
# Cache static assets aggressively # Cache static assets aggressively
@@ -51,9 +46,7 @@ server {
# 127.0.0.1:8081. These headers go with every request passed to it. # 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 # X-Forwarded-For carries only the client address, as resolved by
# the real IP settings above, and not the chain the request came # the real IP settings above, and not the chain the request came
# with. bin/entrypoint.sh starts the backend with # with: the backend takes the first entry, which a client can write.
# 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 Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-For $remote_addr;
+11 -44
View File
@@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"runtime/debug" "runtime/debug"
"slices"
"strings" "strings"
"time" "time"
@@ -134,17 +133,6 @@ func ipFromHostPort(hostPort string) string {
// X-Real-IP are honoured only when the direct peer is a // X-Real-IP are honoured only when the direct peer is a
// trusted proxy; otherwise the direct peer is returned so a // trusted proxy; otherwise the direct peer is returned so a
// spoofed header cannot forge the logged address. // spoofed header cannot forge the logged address.
//
// Each proxy appends to X-Forwarded-For the address it took the
// request from, so the entries on the right are written by the
// trusted proxies and those further left by the client. The
// client's address is therefore the right-most entry that is not
// a trusted proxy. An entry written as an address with a port is
// read as that address, both for the trust check and as the
// result. When every entry is a trusted proxy, or the walk from
// the right meets an entry that is not an address at all (such as
// "unknown"), the peer is used. X-Real-IP is read only when there
// is no X-Forwarded-For entry at all.
func clientIP( func clientIP(
remoteAddr string, remoteAddr string,
header http.Header, header http.Header,
@@ -156,49 +144,28 @@ func clientIP(
return peer return peer
} }
forwarded := forwardedFor(header) if xff := firstForwardedFor(header.Get("X-Forwarded-For")); xff != "" {
return xff
if len(forwarded) == 0 {
if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
return xr
}
return peer
} }
for _, entry := range slices.Backward(forwarded) { if xr := strings.TrimSpace(header.Get("X-Real-IP")); validIP(xr) {
if !validIP(entry) { return xr
return peer
}
if !addrInAny(entry, trusted) {
return entry
}
} }
return peer return peer
} }
// forwardedFor returns the entries of X-Forwarded-For in order, // firstForwardedFor returns the left-most valid address in an
// leaving out blank ones. A proxy may add its entry as a header // X-Forwarded-For list (the original client), or "" if none.
// line of its own rather than append it to the line the client func firstForwardedFor(value string) string {
// sent, so every line is read, not only the first. Some proxies
// write an entry as an address with a port, such as
// 198.51.100.7:5000 or [2001:db8::1]:443; the port is removed so
// that the entry is read as its address.
func forwardedFor(header http.Header) []string {
value := strings.Join(header.Values("X-Forwarded-For"), ",")
var entries []string
for part := range strings.SplitSeq(value, ",") { for part := range strings.SplitSeq(value, ",") {
entry := strings.TrimSpace(part) candidate := strings.TrimSpace(part)
if entry != "" { if validIP(candidate) {
entries = append(entries, ipFromHostPort(entry)) return candidate
} }
} }
return entries return ""
} }
func validIP(s string) bool { func validIP(s string) bool {
+5 -77
View File
@@ -25,16 +25,10 @@ import (
const ( const (
// loopbackPeer is a remote address inside the trusted-proxy allowlist. // loopbackPeer is a remote address inside the trusted-proxy allowlist.
loopbackPeer = "127.0.0.1:5000" loopbackPeer = "127.0.0.1:5000"
// loopbackIP is the address of loopbackPeer, which ClientIP returns
// when it uses the peer.
loopbackIP = "127.0.0.1"
// forwardedIP is the client address presented via X-Forwarded-For. // forwardedIP is the client address presented via X-Forwarded-For.
forwardedIP = "203.0.113.7" forwardedIP = "203.0.113.7"
// realIP is the client address presented via X-Real-IP. // realIP is the client address presented via X-Real-IP.
realIP = "203.0.113.9" 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 { func mustPrefixes(t *testing.T, cidrs ...string) []netip.Prefix {
@@ -91,35 +85,10 @@ func clientIPCases() []clientIPCase {
want: forwardedIP, want: forwardedIP,
}, },
{ {
name: "trusted proxy uses right-most of chain", name: "trusted proxy uses left-most of chain",
remoteAddr: loopbackPeer, remoteAddr: "10.1.2.3:5000",
xff: "203.0.113.9, " + appendedIP, xff: forwardedIP + ", 10.1.2.3",
want: appendedIP, want: forwardedIP,
},
{
name: "trusted proxy entries are skipped from the right",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, 10.0.0.5",
want: "203.0.113.9",
},
{
name: "chain of only trusted proxies uses peer",
remoteAddr: loopbackPeer,
xff: "10.0.0.5, 10.1.2.3",
want: loopbackIP,
},
{
name: "entry that is not an address uses peer",
remoteAddr: loopbackPeer,
xff: "203.0.113.9, unknown",
want: loopbackIP,
},
{
name: "x-real-ip ignored when forwarded-for has entries",
remoteAddr: loopbackPeer,
xff: "10.0.0.5",
xRealIP: realIP,
want: loopbackIP,
}, },
{ {
name: "trusted proxy falls back to x-real-ip", name: "trusted proxy falls back to x-real-ip",
@@ -148,7 +117,7 @@ func clientIPCases() []clientIPCase {
name: "trusted proxy with garbage header uses peer", name: "trusted proxy with garbage header uses peer",
remoteAddr: loopbackPeer, remoteAddr: loopbackPeer,
xff: "not-an-ip", xff: "not-an-ip",
want: loopbackIP, want: "127.0.0.1",
}, },
} }
} }
@@ -179,47 +148,6 @@ func TestClientIP(t *testing.T) {
} }
} }
// TestClientIPReadsEveryForwardedForLine checks that an entry a proxy
// adds as an X-Forwarded-For line of its own is read, and the line the
// client sent before it is not taken.
func TestClientIPReadsEveryForwardedForLine(t *testing.T) {
t.Parallel()
header := http.Header{}
header.Add("X-Forwarded-For", "203.0.113.9")
header.Add("X-Forwarded-For", appendedIP)
got := middleware.ClientIP(loopbackPeer, header,
mustPrefixes(t, "127.0.0.1/32"))
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)
}
}
}
func TestSecurityHeaders(t *testing.T) { func TestSecurityHeaders(t *testing.T) {
t.Parallel() t.Parallel()
+3 -4
View File
@@ -24,10 +24,9 @@ import (
// its number of names from the open file and gives that file to them, // 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. // 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 // This file is built only on Linux, because O_PATH and AT_EMPTY_PATH
// Linux image, and macOS, where the backend is also built for // exist only there; prepare-data-dir runs only in the Linux image. On
// development, has neither O_PATH nor AT_EMPTY_PATH. On any other // any other system chownFile returns an error (see datadir_other.go).
// system chownFile returns an error (see datadir_other.go).
func chownFile(root *os.Root, name string, uid, gid int) error { func chownFile(root *os.Root, name string, uid, gid int) error {
file, err := root.OpenFile(name, unix.O_PATH, 0) file, err := root.OpenFile(name, unix.O_PATH, 0)
if err != nil { if err != nil {
+5 -6
View File
@@ -10,12 +10,11 @@ import (
// chownFile returns an error on every system but Linux. Opening a file // chownFile returns an error on every system but Linux. Opening a file
// that is not a directory without reading it or following it, and // 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 // giving it to a user on the open file, never by name, needs O_PATH
// for Linux (see datadir_linux.go): prepare-data-dir runs only in the // and AT_EMPTY_PATH, which only Linux has (see datadir_linux.go), and
// Linux image, and macOS, where the backend is also built for // prepare-data-dir runs only in the Linux image. This file lets the
// development, has neither O_PATH nor AT_EMPTY_PATH. This file lets // package build elsewhere, such as on macOS, where a developer runs
// the package build elsewhere, such as on macOS, where a developer // make build and make run.
// runs make build and make run.
func chownFile(_ *os.Root, name string, _, _ int) error { func chownFile(_ *os.Root, name string, _, _ int) error {
return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported} return &fs.PathError{Op: "chown", Path: name, Err: errors.ErrUnsupported}
} }
+3 -20
View File
@@ -88,7 +88,7 @@ type Buffer struct {
now func() time.Time now func() time.Time
// seq numbers the report files, so that two named in the same // seq numbers the report files, so that two named in the same
// millisecond still get different names. // millisecond still get different names.
seq atomic.Int64 seq atomic.Uint64
stopOnce sync.Once stopOnce sync.Once
// usedBytes is what Append checks against maxBytes: the size // usedBytes is what Append checks against maxBytes: the size
// of the report files in dataDir, plus the reports waiting to // of the report files in dataDir, plus the reports waiting to
@@ -127,33 +127,16 @@ func New(
lc.Append(fx.Hook{ lc.Append(fx.Hook{
OnStart: func(_ context.Context) error { OnStart: func(_ context.Context) error {
// The errors name DATA_DIR, as config.New's do for the
// settings it checks.
err := os.MkdirAll(b.dataDir, dirPerms) err := os.MkdirAll(b.dataDir, dirPerms)
if err != nil { if err != nil {
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err) return fmt.Errorf("create data dir: %w", err)
}
// Creating and removing a file stops the start when no
// file can be created in DATA_DIR, instead of the first
// write of a report file failing after reports were
// answered 200. A full disk is not caught: the empty
// file is still created.
f, err := os.CreateTemp(b.dataDir, "")
if err != nil {
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
}
err = errors.Join(f.Close(), os.Remove(f.Name()))
if err != nil {
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err)
} }
// Report files left by earlier runs count too, and are // Report files left by earlier runs count too, and are
// the first to be deleted to make room. // the first to be deleted to make room.
files, err := reportFiles(b.dataDir) files, err := reportFiles(b.dataDir)
if err != nil { if err != nil {
return fmt.Errorf("DATA_DIR %q: %w", b.dataDir, err) return err
} }
b.mu.Lock() b.mu.Lock()
-62
View File
@@ -108,68 +108,6 @@ func TestFailedFinalFlushFailsStop(t *testing.T) {
} }
} }
// TestUncreatableDataDirStopsStart sets DATA_DIR below a regular file,
// where no directory can be created, even by root, as the tests run in
// the backend image. The start must fail with an error naming DATA_DIR
// and its value, as the errors for the other settings do.
func TestUncreatableDataDirStopsStart(t *testing.T) {
file := filepath.Join(t.TempDir(), "file")
writeBytes(t, file, 1)
dir := filepath.Join(file, "reports")
t.Setenv("DATA_DIR", dir)
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Invoke(func(*reportbuf.Buffer) {}),
)
err := app.Start(t.Context())
if err == nil {
t.Fatal("start succeeded with DATA_DIR below a regular file")
}
want := fmt.Sprintf("DATA_DIR %q: ", dir)
if !strings.Contains(err.Error(), want) {
t.Fatalf("start error = %q, want it to contain %q", err, want)
}
}
// TestUnwritableDataDirStopsStart sets DATA_DIR to /proc, which exists
// and can be read, but where no file can be created, even by root, as
// the tests run in the backend image: root ignores a directory the test
// makes read-only. The start must fail with an error naming DATA_DIR and
// its value, rather than the first write of a report file.
func TestUnwritableDataDirStopsStart(t *testing.T) {
dir := "/proc"
t.Setenv("DATA_DIR", dir)
app := fxtest.New(t,
fx.Provide(
globals.New,
logger.New,
config.New,
reportbuf.New,
),
fx.Invoke(func(*reportbuf.Buffer) {}),
)
err := app.Start(t.Context())
if err == nil {
t.Fatal("start succeeded with DATA_DIR /proc")
}
want := fmt.Sprintf("DATA_DIR %q: ", dir)
if !strings.Contains(err.Error(), want) {
t.Fatalf("start error = %q, want it to contain %q", err, want)
}
}
// TestStopWaitsForPeriodicWrite holds open the write the periodic flush // TestStopWaitsForPeriodicWrite holds open the write the periodic flush
// starts, then stops the app. The stop must wait until the write is // starts, then stops the app. The stop must wait until the write is
// released, and the report must then be in its file. The test runs in // released, and the report must then be in its file. The test runs in
+9 -14
View File
@@ -2,12 +2,9 @@
# script/cibuild: run the CI build. It bootstraps first: a CI runner # script/cibuild: run the CI build. It bootstraps first: a CI runner
# checks out and runs this and nothing else, and script/fmt-check runs # checks out and runs this and nothing else, and script/fmt-check runs
# the formatter on the host, which a pristine checkout cannot do. # the formatter on the host, which a pristine checkout cannot do.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST # --no-cache for the same reason as script/docker: the gate phases the
# line, since a cached gate phase is a check that did not run; unlike # final stage depends on are RUN steps, and a cached one is a check that
# --no-cache, it keeps the layers above that line and the cache mounts. # did not run.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -17,17 +14,15 @@ main() {
cd "$ROOT" cd "$ROOT"
"$SCRIPT_DIR/bootstrap" "$SCRIPT_DIR/bootstrap"
"$SCRIPT_DIR/check" "$SCRIPT_DIR/check"
# The version and the tag each get their own line: a failing # Own line: a failing command substitution inside an argument does
# command substitution inside an argument does not trip `set -e`, # not trip `set -e`, so the inline form degrades silently to an
# so the inline form degrades silently to an empty constant. The # empty constant. The VERSION build argument takes precedence over
# VERSION build argument takes precedence over the version a build # the version a build stage derives from the .git in the context.
# stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$("$SCRIPT_DIR/projectname")" docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
+8 -14
View File
@@ -1,12 +1,8 @@
#!/bin/sh #!/bin/sh
# script/docker: build the Docker image tagged with the project name. # script/docker: build the Docker image tagged with the project name.
# Identical in all repos; the tag comes from script/projectname. # Identical in all repos; the tag comes from script/projectname.
# A new CACHEBUST value reruns every RUN step after the ARG CACHEBUST # --no-cache because the gate phases the final stage depends on are RUN
# line, since a cached gate phase is a check that did not run; unlike # steps, and a cached one is a check that did not run.
# --no-cache, it keeps the layers above that line and the cache mounts.
# This relies on every stage of the Dockerfile declaring ARG CACHEBUST
# before every step that lints, tests or compiles; a stage without that
# line is served from cache on an unchanged tree, with no warning.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -14,17 +10,15 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
# The version and the tag each get their own line: a failing # Own line: a failing command substitution inside an argument does
# command substitution inside an argument does not trip `set -e`, # not trip `set -e`, so the inline form degrades silently to an
# so the inline form degrades silently to an empty constant. The # empty constant. The VERSION build argument takes precedence over
# VERSION build argument takes precedence over the version a build # the version a build stage derives from the .git in the context.
# stage derives from the .git in the context.
version="$(git describe --tags --always --dirty 2>/dev/null || true)" version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown" [ -n "$version" ] || version="unknown"
tag="$("$SCRIPT_DIR/projectname")" docker build --no-cache \
docker build --build-arg CACHEBUST="$(date +%s)-$$" \
--build-arg VERSION="$version" \ --build-arg VERSION="$version" \
-t "$tag" . -t "$("$SCRIPT_DIR/projectname")" .
} }
main "$@" main "$@"
-28
View File
@@ -7,8 +7,6 @@
# headless Chrome at it over CDP, and asserts on computed layout at every # headless Chrome at it over CDP, and asserts on computed layout at every
# viewport width derived from the app's own CSS. See # viewport width derived from the app's own CSS. See
# test/viewport/README.md for what this covers and what it cannot. # 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 # Deliberately not part of script/check: it needs Docker and takes far
# longer than the 20s budget make test has to stay inside. # longer than the 20s budget make test has to stay inside.
@@ -77,32 +75,6 @@ main() {
-v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \ -v "$ROOT/configs/security-headers.conf:/etc/nginx/security-headers.conf:ro" \
"$SERVER_IMAGE" > /dev/null "$SERVER_IMAGE" > /dev/null
# The page, and /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 # The image's own entrypoint already exposes CDP on 9222 and passes
# --no-sandbox, so only extra flags belong here; re-specifying the # --no-sandbox, so only extra flags belong here; re-specifying the
# debugging port collides with it and leaves the endpoint bound to # debugging port collides with it and leaves the endpoint bound to
+5 -10
View File
@@ -5,14 +5,9 @@
# make its answer untrustworthy. # make its answer untrustworthy.
# #
# The phase is not the last stage in the file, so it is built only when # The phase is not the last stage in the file, so it is built only when
# --target names it. A new CACHEBUST value reruns every RUN step after # --target names it. --no-cache because a cached lint layer is a lint
# the ARG CACHEBUST line, since a cached lint layer is a lint that did # that did not run. The tag makes each build replace the previous image
# not run; unlike --no-cache, it keeps the layers above that line and # instead of leaving a dangling one behind.
# the cache mounts. This relies on every stage of the Dockerfile
# declaring ARG CACHEBUST before every step that lints, tests or
# compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning.
# --output type=cacheonly writes no image, since nothing uses one.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -20,9 +15,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --no-cache \
--target lint \ --target lint \
--output type=cacheonly . -t "$("$SCRIPT_DIR/projectname")-lint" .
} }
main "$@" main "$@"
+4 -9
View File
@@ -2,13 +2,8 @@
# script/test: run the test suite. Testing is a phase of the Dockerfile # script/test: run the test suite. Testing is a phase of the Dockerfile
# and this builds that phase alone, on the same terms as script/lint: # and this builds that phase alone, on the same terms as script/lint:
# --target because a phase that is not the last stage is built only when # --target because a phase that is not the last stage is built only when
# named, and a new CACHEBUST value because a cached test layer is a test # named, --no-cache because a cached test layer is a test that did not
# that did not run; unlike --no-cache, it keeps the layers above the ARG # run, and a tag so each build replaces the previous image.
# CACHEBUST line and the cache mounts. This relies on every stage of the
# Dockerfile declaring ARG CACHEBUST before every step that lints, tests
# or compiles; a stage without that line is served from cache on an
# unchanged tree, with no warning. --output type=cacheonly writes no
# image, since nothing uses one.
set -eu set -eu
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
@@ -16,9 +11,9 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
docker build --build-arg CACHEBUST="$(date +%s)-$$" \ docker build --no-cache \
--target test \ --target test \
--output type=cacheonly . -t "$("$SCRIPT_DIR/projectname")-test" .
} }
main "$@" main "$@"
+11 -106
View File
@@ -6,7 +6,6 @@ import { beforeEach, test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
AppState, AppState,
buildUI,
CONFIG, CONFIG,
greyOutUI, greyOutUI,
hostRowHTML, hostRowHTML,
@@ -18,7 +17,6 @@ import {
measureLatency, measureLatency,
Reporter, Reporter,
tick, tick,
updateClocks,
updateSummary, updateSummary,
} from "../../web/src/main.js"; } from "../../web/src/main.js";
@@ -30,10 +28,8 @@ import {
// selector until the next test starts. As on a page, writing its text // 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 // 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 // there. Drawing a sparkline does nothing; it looks for the pixel ratio on
// window and finds none. The commit the page's footer names, which // window and finds none. In each test, console.error and console.info
// vite.config.js fills in when it builds the page, is blank. In each test, // print nothing and keep what they are given.
// console.error and console.info print nothing and keep what they are
// given.
const doNothing = () => {}; const doNothing = () => {};
let elements; let elements;
beforeEach((t) => { beforeEach((t) => {
@@ -55,8 +51,6 @@ const canvasContext = {
globalThis.window = {}; globalThis.window = {};
globalThis.requestAnimationFrame = doNothing; globalThis.requestAnimationFrame = doNothing;
globalThis.cancelAnimationFrame = doNothing; globalThis.cancelAnimationFrame = doNothing;
globalThis.__COMMIT_HASH__ = "";
globalThis.__COMMIT_FULL__ = "";
globalThis.document = { globalThis.document = {
getElementById: () => null, getElementById: () => null,
createElement: () => ({}), createElement: () => ({}),
@@ -83,11 +77,6 @@ function statusText(state, host) {
return elements[`.status-text[data-host="${index}"]`]?.innerHTML; return elements[`.status-text[data-host="${index}"]`]?.innerHTML;
} }
// What this test wrote to the browser console with console[method].
function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[0]);
}
// Mocks the clock for test t, so that a check lasting seconds takes no real // Mocks the clock for test t, so that a check lasting seconds takes no real
// time, and replaces fetch with targets that each answer after // time, and replaces fetch with targets that each answer after
// answerAfter(url) milliseconds of that clock, or never when that is // answerAfter(url) milliseconds of that clock, or never when that is
@@ -207,10 +196,9 @@ test("at a 30000ms interval, a target answering after 1000ms shows in its row wh
}); });
// In the next three tests, the answering target's check is still waiting // In the next three tests, the answering target's check is still waiting
// when something happens after which its result must not be recorded or // when something happens after which its result must not show.
// 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 is not recorded and does not show in its row", async (t) => { test("at a 30000ms interval, a check still waiting when its round is given up does not show in its row", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
{ name: "Answering", url: "https://answering.test" }, { name: "Answering", url: "https://answering.test" },
@@ -226,13 +214,9 @@ test("at a 30000ms interval, a check still waiting when its round is given up is
roundChecks.abort(); roundChecks.abort();
assert.notEqual(await settled(round), "still waiting"); assert.notEqual(await settled(round), "still waiting");
assert.equal(latencyFigure(state, answering), undefined); assert.equal(latencyFigure(state, answering), undefined);
assert.deepEqual(answering.history, []);
assert.equal(answering.status, "pending");
assert.deepEqual(consoleLines("error"), []);
assert.deepEqual(consoleLines("info"), []);
}); });
test("at a 30000ms interval, a check still waiting when the user pauses is not recorded and does not show in its row", async (t) => { test("at a 30000ms interval, a check still waiting when the user pauses does not show in its row", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
{ name: "Answering", url: "https://answering.test" }, { name: "Answering", url: "https://answering.test" },
@@ -247,13 +231,9 @@ test("at a 30000ms interval, a check still waiting when the user pauses is not r
t.mock.timers.tick(500); t.mock.timers.tick(500);
assert.equal(await settled(round), "still waiting"); assert.equal(await settled(round), "still waiting");
assert.equal(latencyFigure(state, answering), undefined); assert.equal(latencyFigure(state, answering), undefined);
assert.deepEqual(answering.history, []);
assert.equal(answering.status, "pending");
assert.deepEqual(consoleLines("error"), []);
assert.deepEqual(consoleLines("info"), []);
}); });
test("at a 30000ms interval, a check in the first round is not recorded and does not show in its row", async (t) => { test("at a 30000ms interval, a check in the first round does not show in its row", async (t) => {
CONFIG.updateInterval = 30000; CONFIG.updateInterval = 30000;
const state = new AppState([ const state = new AppState([
{ name: "Answering", url: "https://answering.test" }, { name: "Answering", url: "https://answering.test" },
@@ -265,10 +245,6 @@ test("at a 30000ms interval, a check in the first round is not recorded and does
t.mock.timers.tick(1000); t.mock.timers.tick(1000);
assert.equal(await settled(round), "still waiting"); assert.equal(await settled(round), "still waiting");
assert.equal(latencyFigure(state, answering), undefined); assert.equal(latencyFigure(state, answering), undefined);
assert.deepEqual(answering.history, []);
assert.equal(answering.status, "pending");
assert.deepEqual(consoleLines("error"), []);
assert.deepEqual(consoleLines("info"), []);
}); });
test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => { test("at a 30000ms interval, after the user pauses and resumes during a round, no row reads paused once its last check ends", async (t) => {
@@ -318,6 +294,11 @@ function stateWithOneTarget() {
return { state, target: state.local[0] }; return { state, target: state.local[0] };
} }
// What this test wrote to the browser console with console[method].
function consoleLines(method) {
return console[method].mock.calls.map((call) => call.arguments[0]);
}
test("a check that fails with a network error writes one console line with the target, the time, the error and how long the request took", async (t) => { test("a check that fails with a network error writes one console line with the target, the time, the error and how long the request took", async (t) => {
const { state, target } = stateWithOneTarget(); const { state, target } = stateWithOneTarget();
mockTargets(t, () => 23, target.url); mockTargets(t, () => 23, target.url);
@@ -598,32 +579,6 @@ test("a check that timed out or found its target unreachable has a grey figure a
assert.equal(latencyHex(null), "#6b7280"); 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, // A target whose checks, in turn, answered after each of latencies ms, or,
// for null, found it unreachable. // for null, found it unreachable.
function hostAfter(latencies) { function hostAfter(latencies) {
@@ -746,56 +701,6 @@ test("once every WAN target's check has failed, the summary's Reachable slot rea
assert.equal(elements["#summary-reachable"].className, "text-red-400"); 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, // An app state in which, of the WAN targets, the first timedOut timed out,
// the next unreachable were found unreachable, the next answered answered // the next unreachable were found unreachable, the next answered answered
// after latency ms, and the rest have not been checked yet. // after latency ms, and the rest have not been checked yet.
+20 -34
View File
@@ -829,7 +829,7 @@ function wanRowsHTML(wanHosts) {
return rows.join(""); return rows.join("");
} }
export function buildUI(state) { function buildUI(state) {
const app = document.getElementById("app"); const app = document.getElementById("app");
app.innerHTML = ` app.innerHTML = `
<div class="mx-auto px-[5%] py-8"> <div class="mx-auto px-[5%] py-8">
@@ -911,7 +911,7 @@ export 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-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-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-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 <span class="inline-block w-3 h-3 rounded-full bg-gray-500 mr-1 ml-3 align-middle"></span>offline
</p> </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> <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,33 +1114,6 @@ function updateHealthBox(state) {
else if (health === "healthy") log.notice(`Health: ${labels[health]}`); 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 ------------------------------------------------------ // --- Debug Log Renderer ------------------------------------------------------
// One line of the debug log panel, its message set as text. // One line of the debug log panel, its message set as text.
@@ -1536,6 +1509,24 @@ async function init() {
sortAndRebuildWAN(state); 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 // 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 // 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 // more. At a steady interval they never are, as they time out at 80% of
@@ -1615,11 +1606,6 @@ async function init() {
window.addEventListener("resize", () => handleResize(state)); window.addEventListener("resize", () => handleResize(state));
setTimeout(() => handleResize(state), CONFIG.resizeDelay); 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 // Bootstrap only when loaded as the page: a real DOM containing the #app