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

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

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

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

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

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

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

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

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

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

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

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

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

Model: opus-5-5
2026-10-19 06:55:51 +02:00
23 changed files with 760 additions and 188 deletions
+4 -1
View File
@@ -73,9 +73,12 @@
# This repository's own entries, after the shared content above: the
# frontend build, the viewport test's output, the binary `make build`
# writes and the reports `make run` stores, and log files at the root.
# writes and the reports `make run` stores, and Go test binaries,
# coverage output and log files at the root.
/dist
/tmp
/netwatch-server
/data
/*.test
/*.out
/*.log
+3 -2
View File
@@ -11,7 +11,8 @@ insert_final_newline = true
[Makefile]
indent_style = tab
# This repository's own entries, after the shared content above.
[*.go]
indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
+11
View File
@@ -1,9 +1,20 @@
name: check
on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check:
runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild
+4 -2
View File
@@ -27,7 +27,7 @@ node_modules/
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
# its own negation at the end of this file, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
@@ -46,7 +46,9 @@ node_modules/
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, after the shared content above.
# This repository's own entries, such as its build outputs, go below
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# Build output
dist/
+2
View File
@@ -25,6 +25,8 @@ linters:
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
# Misses findings at random in v2.14.0; back once a pinned release fixes it
- canonicalheader
settings:
lll:
line-length: 88
+24 -9
View File
@@ -55,7 +55,8 @@ either one is broken. We provide:
- `script/build` — build the frontend for production into `dist/`, then the
static `netwatch-server` binary into the repo root with its version stamped
in: `VERSION` from the environment, or when that is unset or empty,
`git describe` inside a git checkout, then `dev`
`git describe --tags --always --dirty` inside a git checkout, as
`script/docker` does, then `dev`
- `script/run` — build with `script/build`, then run `netwatch-server` with its
defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes
@@ -207,7 +208,9 @@ stops. Its routes:
address may send a limited number a minute, and the report files are capped in
size, the oldest deleted first
- `GET /.well-known/healthcheck` — answers 200 with `"status":"ok"`, the
server's version and its uptime
server's version and its uptime; while reports cannot be stored, as the last
write of a report file failed, it answers 503 with `"status":"error"` and the
same fields (see [Report storage](#report-storage))
- `GET /metrics` — Prometheus metrics behind basic auth, only when
`METRICS_USERNAME` and `METRICS_PASSWORD` are set; each client address may
make a limited number of requests to it a minute
@@ -215,6 +218,10 @@ stops. Its routes:
It is an `fx`-wired Go application: `cmd/netwatch-server/` starts it, and its
packages are in `internal/`:
- **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and
`prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it
starts the server, and, given no arguments, the server, with the `fx` wiring.
It refuses any other arguments with a usage message and exit code 2.
- **`config`**: Loads configuration from environment variables and config files
via Viper.
- **`handlers`**: HTTP request handlers for the API (health check, report
@@ -282,10 +289,11 @@ The reports of a failed write stay in memory, and the next flush, within a
minute, tries again to write them. The server writes one file at a time, so a
file holds its reports in the order they arrived, those of a failed write
included. From the failed write until a write succeeds, `POST /api/v1/reports`
refuses every report with 500 and keeps nothing of it. The page sends the
refused samples again in its next report, as long as it still holds them: at the
1-second interval one refused report already loses some of those in the history
(see [Reporting](#reporting)). Reports already answered 200 are lost only if the
refuses every report with 500 and keeps nothing of it, and the health check
answers 503 with `"status":"error"`. The page sends the refused samples again in
its next report, as long as it still holds them: at the 1-second interval one
refused report already loses some of those in the history (see
[Reporting](#reporting)). Reports already answered 200 are lost only if the
server stops before a write succeeds; the final flush at stop tries them too,
and if it fails, the process exits non-zero.
@@ -559,9 +567,16 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
stops the container. Empty, the backend sends nothing to Sentry
- **Health check:** the image's `HEALTHCHECK` requests
`/.well-known/healthcheck` through nginx every 30 seconds, so it fails unless
both nginx and the backend answer. upaas reads the container's health 60
seconds after a deploy and fails the deploy unless it is `healthy`. The
container also stops when either process exits.
both nginx and the backend answer. It also fails while reports cannot be
stored, as when the volume is full or `DATA_DIR` cannot be written: the health
check answers 503 from a failed write of a report file until a write succeeds,
and the backend tries the write again every minute. Three failed checks in a
row make the container `unhealthy`, and one that passes makes it `healthy`
again. upaas reads the container's health once, 60 seconds after a deploy, and
fails the deploy unless it is `healthy`. A container that turns `unhealthy`
after that is neither restarted nor reported by upaas, whose `unless-stopped`
restart policy ignores health; only `docker ps` and `docker inspect` show it.
The container also stops when either process exits.
## Browser Compatibility
+88 -4
View File
@@ -9,10 +9,12 @@
# Status
pre-1.0. No git tags. `main` is the stable branch and `next` the development
branch, which every PR targets. The frontend and the Go backend ship as one
Docker image, and the Gitea workflow `.gitea/workflows/check.yml` runs
`script/cibuild` on every push. Working toward 1.0.0.
pre-1.0. Release candidates are tagged on `next`, the first being `v1.0.0-rc1`;
`git tag --merged origin/next` lists them. `main` is the stable branch and
`next` the development branch, which every PR targets. The frontend and the Go
backend ship as one Docker image, and the Gitea workflow
`.gitea/workflows/check.yml` runs `script/cibuild` on every push, and cancels
the older run on the same branch. Working toward 1.0.0.
# Next Step
@@ -21,6 +23,72 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
# Completed Steps
- 2026-10-19: the server's `OnStart` hook in `internal/server/server.go` starts
`listenAndServe` with `go func() { s.listenAndServe() }()`, as the Go style
guide asks ([#162](https://git.eeqj.de/sneak/netwatch/issues/162)); it was
`go s.listenAndServe()`. No other `go` statement in the Go code called a
function directly. Nothing the server does changed
- 2026-10-19: `.gitignore`, `.editorconfig` and `.golangci.yml` are the
sneak/prompts copies at `0653579`
([#174](https://git.eeqj.de/sneak/netwatch/issues/174)). `.gitignore` is the
standard file followed by this repository's own entries; `.editorconfig` is
the standard file alone, which now carries the `[*.go]` section.
`.golangci.yml` disables `canonicalheader`, as the canonical copy does,
because golangci-lint v2.14.0 misses its findings at random
- 2026-10-19: `package.json` has no `version` field and is marked
`"private": true` ([#158](https://git.eeqj.de/sneak/netwatch/issues/158)). It
said `1.0.0`, a release that has not been made, and nothing read it; the git
tag is the one version, which the image and `make build` take from
`git describe --tags`. The package is never published
- 2026-10-19: Status names `v1.0.0-rc1`, the first release candidate, and says
release candidates are tagged on `next`, where it said there were no git tags
([#157](https://git.eeqj.de/sneak/netwatch/issues/157)). The rest of Status
matches the tree
- 2026-10-19: `netwatch-server` starts the server only when given no arguments
([#179](https://git.eeqj.de/sneak/netwatch/issues/179)). `check-cidr CIDR` and
`prepare-data-dir DATA_DIR` behave as before; any other arguments, such as a
subcommand with no value or two, or a misspelt one, print a one-line usage
message naming both forms to stderr and exit 2. They used to start the server.
Unit tests call `cli.Main` with no arguments and check that it starts the
server, and with each subcommand given no value and two values and with a
misspelt subcommand, and check the exit code, the message and that the server
does not start
- 2026-10-19: `main()` in `cmd/netwatch-server/main.go` is one call into a new
package, `internal/cli`, as the Go style guide asks
([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs
the `check-cidr` and `prepare-data-dir` subcommands, with the same arguments,
messages and exit codes, and otherwise the server, with the `fx` wiring.
`Appname` and `Version` stay in `main`, where the build sets `Version`, and
`main` gives `cli.Main` stderr and the function that runs the server, which
the tests replace with a buffer and a function that fails them. Unit tests
call `cli.Main` with `check-cidr` and a CIDR the server takes and one it
refuses, and check the exit code and what it writes to stderr, and with
`prepare-data-dir` and a path outside `/data`, and check that it exits 1 with
the error from looking up the `netwatch` user, which the test image does not
have. Others give the functions behind the subcommands input they take and
input they refuse, and check the exit code and what they write to stderr
- 2026-10-19: when `VERSION` is unset or empty, `script/build` stamps the
version from `git describe --tags --always --dirty`, as `script/docker` does
([#156](https://git.eeqj.de/sneak/netwatch/issues/156)). Without `--tags` it
skipped lightweight tags, so `make run` could report a commit where the image
built from the same tree reported the tag. Checked once with a lightweight tag
on a scratch clone: `make build` stamped it
- 2026-10-19: `.dockerignore` keeps Go test binaries and coverage output at the
root out of the build context, with `/*.test` and `/*.out` in the repository's
own section ([#155](https://git.eeqj.de/sneak/netwatch/issues/155)). Such
files went into the build context of every `docker build` in `script/` and
were copied into the lint, test and build stages. Checked with a probe image
that copies the whole context: `x.test` and `x.out` planted at the root no
longer land in it, and nothing else changed. The entries match only at the
root, so the same files in a subdirectory still reach the image
- 2026-10-19: `.gitea/workflows/check.yml` is the sneak/prompts copy at
`0653579` ([#153](https://git.eeqj.de/sneak/netwatch/issues/153)). Its
checkout step sets `fetch-depth: 0`, so CI clones all history and tags, and
`git describe` in `script/cibuild` gives the image built from a tagged commit
the tag as its version; it gave the short commit. The checkout step also sets
`persist-credentials: false`, so the job's token is not left in `.git/config`.
A new push cancels the older run on the same branch, and a run stops after 20
minutes
- 2026-10-19: `script/bootstrap` keeps the `yarn` on `PATH` only when
`yarn --version` is exactly `YARN_VERSION`, and otherwise installs that
version as it did when no yarn was installed
@@ -35,6 +103,22 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
script puts `~/.local/bin` first on `PATH`, uses the first `node` on that
`PATH` when it passes the same test, and otherwise runs yarn under the pinned
node in nvm; `make build` ran yarn under whatever node was first on `PATH`
- 2026-10-19: `bin/entrypoint.sh` has each of its steps in a function, and a
`main` at the end calls them in order, as the shell section of the code style
guide asks ([#163](https://git.eeqj.de/sneak/netwatch/issues/163)). It is
still POSIX sh with no `set -e`, and does what it did, with the same messages
and exit codes. Checked by running the image with each script: a normal start,
bad `PORT`, `TRUSTED_PROXIES` and `DATA_DIR` values, `docker stop`, INT, and
nginx exiting on its own
- 2026-10-19: while report files cannot be written, the health check answers 503
with `"status":"error"` and the same fields, so the image's `HEALTHCHECK`
reports the container `unhealthy`
([#142](https://git.eeqj.de/sneak/netwatch/issues/142)). It answered 200
`"status":"ok"` whatever happened, so the platform saw a healthy container
while every report was refused with 500. It answers 200 at start, before any
write has failed, and again from the first write that succeeds. A unit test
removes `DATA_DIR` to make a write fail and checks the 503, then puts it back
and checks the 200 after the next write
- 2026-10-19: report file writes no longer overlap
([#150](https://git.eeqj.de/sneak/netwatch/issues/150)), so the reports of
failed writes are written in the order they arrived. Two writes that failed at
+91 -63
View File
@@ -13,24 +13,27 @@ set -u
# address and start anyway, and reports a bad port without naming
# PORT, so a value that is not a usable port stops the container here,
# before either process starts.
export PORT="${PORT:-8080}"
case "$PORT" in
*[!0-9]*)
echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
check_port() {
export PORT="${PORT:-8080}"
case "$PORT" in
*[!0-9]*)
echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
exit 1
;;
esac
# The length is checked first because, for a number too big for it,
# the shell's test prints an error and is false, so the range checks
# alone would let it through.
if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2
exit 1
;;
esac
# The length is checked first because, for a number too big for it,
# the shell's test prints an error and is false, so the range checks
# alone would let it through.
if [ "${#PORT}" -gt 5 ] || [ "$PORT" -lt 1 ] || [ "$PORT" -gt 65535 ]; then
echo "entrypoint: PORT must be from 1 to 65535, not '$PORT'" >&2
exit 1
fi
if [ "$PORT" -eq 8081 ]; then
echo "entrypoint: PORT cannot be 8081, netwatch-server listens there" >&2
exit 1
fi
fi
if [ "$PORT" -eq 8081 ]; then
echo "entrypoint: PORT cannot be 8081, netwatch-server" \
"listens there" >&2
exit 1
fi
}
# TRUSTED_PROXIES names the reverse proxies in front of the container,
# as IP addresses or CIDRs separated by commas. nginx takes the client
@@ -47,21 +50,23 @@ fi
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
# below, naming the entry as written. set -f keeps a * in an entry from
# becoming a list of file names.
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
set -f
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in
*/*) cidr="$proxy" ;;
*:*) cidr="$proxy/128" ;;
*) cidr="$proxy/32" ;;
esac
if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then
echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \
"separated by commas; '$proxy' is neither" >&2
exit 1
fi
echo "set_real_ip_from $cidr;"
done > /etc/nginx/trusted-proxies.conf
write_trusted_proxies() {
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
set -f
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
case "$proxy" in
*/*) cidr="$proxy" ;;
*:*) cidr="$proxy/128" ;;
*) cidr="$proxy/32" ;;
esac
if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then
echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \
"separated by commas; '$proxy' is neither" >&2
exit 1
fi
echo "set_real_ip_from $cidr;"
done > /etc/nginx/trusted-proxies.conf
}
# netwatch-server keeps its report files in DATA_DIR, on the /data
# volume, which may be a host directory owned by root or by another
@@ -73,12 +78,10 @@ done > /etc/nginx/trusted-proxies.conf
# naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it
# acts on nothing outside /data, whatever symbolic or hard links it
# meets there.
export DATA_DIR="${DATA_DIR:-/data/reports}"
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
# A stop signal is only noted here; the loop below acts on it.
stop_requested=""
trap 'stop_requested=yes' TERM INT
prepare_data_dir() {
export DATA_DIR="${DATA_DIR:-/data/reports}"
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
}
# netwatch-server runs as the netwatch user and listens on loopback
# only, on a port other than the public one; configs/nginx.conf proxies
@@ -87,9 +90,11 @@ trap 'stop_requested=yes' TERM INT
# the container has. The netwatch user has no login shell, hence -s
# /bin/sh. busybox su replaces itself with the command instead of
# staying on as its parent, so $! is the server's own PID.
BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \
su -s /bin/sh netwatch -c 'exec netwatch-server' &
backend=$!
start_backend() {
BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \
su -s /bin/sh netwatch -c 'exec netwatch-server' &
backend=$!
}
# nginx starts through the nginx image's own entrypoint, which applies
# the image's start-up configuration and then replaces itself with
@@ -98,9 +103,11 @@ backend=$!
# that rendering to PORT: a variable nginx itself uses, such as $uri,
# would otherwise be replaced by an environment variable of the same
# name.
NGINX_ENVSUBST_FILTER='^PORT$' \
/docker-entrypoint.sh nginx -g 'daemon off;' &
nginx=$!
start_nginx() {
NGINX_ENVSUBST_FILTER='^PORT$' \
/docker-entrypoint.sh nginx -g 'daemon off;' &
nginx=$!
}
running() {
kill -0 "$1" 2>/dev/null
@@ -109,26 +116,47 @@ running() {
# POSIX sh cannot wait for whichever of two children exits first, so
# look once a second. The shell collects a child that has exited while
# it runs sleep, and running() is false for that child from then on.
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
sleep 1
done
wait_for_stop_or_exit() {
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
sleep 1
done
}
# Stop both, then wait until neither is left.
kill -TERM "$backend" "$nginx" 2>/dev/null
while running "$backend" || running "$nginx"; do
sleep 1
done
wait "$backend"
backend_status=$?
wait "$nginx"
nginx_status=$?
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
stop_both() {
kill -TERM "$backend" "$nginx" 2>/dev/null
while running "$backend" || running "$nginx"; do
sleep 1
done
}
# Success is a requested stop that both processes exited cleanly from.
if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
[ "$nginx_status" -eq 0 ]; then
exit 0
fi
exit 1
exit_with_status() {
wait "$backend"
backend_status=$?
wait "$nginx"
nginx_status=$?
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
[ "$nginx_status" -eq 0 ]; then
exit 0
fi
exit 1
}
main() {
check_port
write_trusted_proxies
prepare_data_dir
# A stop signal is only noted here; wait_for_stop_or_exit acts on it.
stop_requested=""
trap 'stop_requested=yes' TERM INT
start_backend
start_nginx
wait_for_stop_or_exit
stop_both
exit_with_status
}
main "$@"
+4 -74
View File
@@ -1,22 +1,11 @@
// Package main is the entry point for netwatch-server.
// Package main is the entry point for netwatch-server; package cli
// does the work.
package main
import (
"fmt"
"os"
"os/user"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/netwatch/internal/cli"
)
//nolint:gochecknoglobals // set via ldflags at build time
@@ -26,64 +15,5 @@ var (
)
func main() {
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
// this server would refuse CIDR in its TRUSTED_PROXIES.
// bin/entrypoint.sh runs it on each entry it gives nginx.
if len(os.Args) == 3 && os.Args[1] == "check-cidr" {
_, err := middleware.ParseTrustedProxies(os.Args[2:])
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
return
}
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
// for the netwatch user, or exits 1 with the error; see
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
// before it starts this server as that user.
if len(os.Args) == 3 && os.Args[1] == "prepare-data-dir" {
netwatch, err := user.Lookup("netwatch")
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
err = reportbuf.PrepareDataDir("/data", os.Args[2], netwatch)
if err != nil {
fmt.Fprintf(os.Stderr, "DATA_DIR '%s': %v\n", os.Args[2], err)
os.Exit(1)
}
return
}
globals.Appname = Appname
globals.Version = Version
fx.New(
// fx logs each step of starting and stopping through the
// server's own logger, so off a terminal those lines are
// JSON like every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
return &fxevent.SlogLogger{Logger: log.Get()}
}),
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Invoke(
// First, so the name and version are logged even when
// a setting stops the start.
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
os.Exit(cli.Main(os.Stderr, Appname, Version, os.Args[1:], cli.RunServer))
}
+143
View File
@@ -0,0 +1,143 @@
// Package cli runs netwatch-server from its command line: the
// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh
// runs before it starts the server, and, given no arguments, the
// server, with the fx wiring.
package cli
import (
"fmt"
"io"
"os/user"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/middleware"
"sneak.berlin/go/netwatch/internal/reportbuf"
"sneak.berlin/go/netwatch/internal/server"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
)
// usageExitCode is what Main returns for a command line it does not
// understand, the usual exit code for a usage error.
const usageExitCode = 2
// Main runs netwatch-server with args, its command line after the
// program's name, and returns the exit code. With no args, Main runs
// the server by calling serve with appname and version, the name and
// version the server logs and reports. With "check-cidr CIDR" or
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its
// errors to stderr. Any other args get a one-line usage message on
// stderr and exit code 2. main passes os.Stderr and RunServer; the
// tests pass a buffer and a function of their own, so that no test
// starts the server. When serve is RunServer and the server fails to
// start, to listen or to stop, fx's Run exits the process with status 1
// and Main never returns.
func Main(
stderr io.Writer,
appname, version string,
args []string,
serve func(appname, version string),
) int {
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
// this server would refuse CIDR in its TRUSTED_PROXIES.
// bin/entrypoint.sh runs it on each entry it gives nginx.
if len(args) == 2 && args[0] == "check-cidr" {
return checkCIDR(stderr, args[1])
}
// "netwatch-server prepare-data-dir DATA_DIR" gets DATA_DIR ready
// for the netwatch user, or exits 1 with the error; see
// reportbuf.PrepareDataDir. bin/entrypoint.sh runs it as root
// before it starts this server as that user.
if len(args) == 2 && args[0] == "prepare-data-dir" {
return prepareDataDir(stderr, "/data", args[1], "netwatch")
}
// Any other command line is refused, so that a mistyped one does
// not start the server.
if len(args) > 0 {
_, _ = fmt.Fprintln(stderr,
"usage: netwatch-server [check-cidr CIDR | prepare-data-dir DATA_DIR]")
return usageExitCode
}
serve(appname, version)
return 0
}
// checkCIDR returns 0 if the server would take cidr in its
// TRUSTED_PROXIES, and otherwise writes the error to stderr and
// returns 1.
func checkCIDR(stderr io.Writer, cidr string) int {
_, err := middleware.ParseTrustedProxies([]string{cidr})
if err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 1
}
return 0
}
// prepareDataDir gets dataDir ready for the user named username with
// reportbuf.PrepareDataDir, volume being where the data volume is
// mounted, and returns 0. Otherwise it writes the error to stderr and
// returns 1.
func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
owner, err := user.Lookup(username)
if err != nil {
_, _ = fmt.Fprintln(stderr, err)
return 1
}
err = reportbuf.PrepareDataDir(volume, dataDir, owner)
if err != nil {
_, _ = fmt.Fprintf(stderr, "DATA_DIR '%s': %v\n", dataDir, err)
return 1
}
return 0
}
// RunServer runs the server until it is told to stop, with appname and
// version as the name and version it logs and reports. If the server
// fails to start, to listen or to stop, fx's Run exits the process
// with status 1 itself.
func RunServer(appname, version string) {
globals.Appname = appname
globals.Version = version
fx.New(
// fx logs each step of starting and stopping through the
// server's own logger, so off a terminal those lines are
// JSON like every other line.
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
return &fxevent.SlogLogger{Logger: log.Get()}
}),
fx.Provide(
config.New,
globals.New,
handlers.New,
healthcheck.New,
logger.New,
middleware.New,
reportbuf.New,
server.New,
),
fx.Invoke(
// First, so the name and version are logged even when
// a setting stops the start.
func(log *logger.Logger) { log.Identify() },
func(*server.Server) {},
),
).Run()
}
+219
View File
@@ -0,0 +1,219 @@
package cli_test
import (
"bytes"
"os"
"os/user"
"path/filepath"
"strconv"
"strings"
"testing"
"sneak.berlin/go/netwatch/internal/cli"
"sneak.berlin/go/netwatch/internal/reportbuf"
)
// TestMainRunsCheckCIDR: Main given check-cidr returns 0, writing
// nothing, for a CIDR the server takes in its TRUSTED_PROXIES, and 1,
// with an error naming TRUSTED_PROXIES and the CIDR, for one it
// refuses, and does not start the server.
func TestMainRunsCheckCIDR(t *testing.T) {
t.Parallel()
var stderr bytes.Buffer
code := cli.Main(&stderr, "netwatch", "test",
[]string{"check-cidr", "10.0.0.0/8"}, failIfServerStarts(t))
if code != 0 || stderr.Len() != 0 {
t.Errorf("check-cidr 10.0.0.0/8: exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
stderr.Reset()
code = cli.Main(&stderr, "netwatch", "test",
[]string{"check-cidr", "1.2.3/32"}, failIfServerStarts(t))
want := `TRUSTED_PROXIES "1.2.3/32": `
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
t.Errorf("check-cidr 1.2.3/32: exit %d, stderr %q; want exit 1 and %q...",
code, stderr.String(), want)
}
}
// TestMainRunsPrepareDataDir: Main given prepare-data-dir runs the
// prepare-data-dir subcommand for the netwatch user, and does not start
// the server. The test image has no netwatch user, so that subcommand
// exits 1 with the error from looking the user up;
// TestPrepareDataDirRefusesPathOutsideVolume checks the path.
func TestMainRunsPrepareDataDir(t *testing.T) {
t.Parallel()
var stderr bytes.Buffer
code := cli.Main(&stderr, "netwatch", "test",
[]string{"prepare-data-dir", "/etc"}, failIfServerStarts(t))
want := user.UnknownUserError("netwatch").Error() + "\n"
if code != 1 || stderr.String() != want {
t.Errorf("prepare-data-dir /etc: exit %d, stderr %q; want exit 1 and %q",
code, stderr.String(), want)
}
}
// TestMainRunsServerWithNoArguments: Main given no arguments starts the
// server with the name and version it was given, writes nothing and
// returns 0.
func TestMainRunsServerWithNoArguments(t *testing.T) {
t.Parallel()
var (
stderr bytes.Buffer
gotAppname, gotVersion string
)
code := cli.Main(&stderr, "netwatch", "test", []string{},
func(appname, version string) {
gotAppname, gotVersion = appname, version
})
if code != 0 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
if gotAppname != "netwatch" || gotVersion != "test" {
t.Errorf("server started with %q %q; want %q %q",
gotAppname, gotVersion, "netwatch", "test")
}
}
// TestMainRefusesOtherCommandLines: Main given a subcommand with too
// few or too many values, or a misspelt subcommand, writes the usage
// message to stderr, returns 2 and does not start the server.
func TestMainRefusesOtherCommandLines(t *testing.T) {
t.Parallel()
want := "usage: netwatch-server " +
"[check-cidr CIDR | prepare-data-dir DATA_DIR]\n"
for _, commandLine := range []string{
"netwatch-server check-cidr",
"netwatch-server check-cidr 192.0.2.0/24 198.51.100.0/24",
"netwatch-server prepare-data-dir",
"netwatch-server prepare-data-dir /data/reports /data/other",
"netwatch-server check-cdir 192.0.2.0/24",
} {
var stderr bytes.Buffer
// Main takes the command line after the program's name.
args := strings.Fields(commandLine)[1:]
code := cli.Main(&stderr, "netwatch", "test", args, failIfServerStarts(t))
if code != 2 || stderr.String() != want {
t.Errorf("%q: exit %d, stderr %q; want exit 2 and %q",
commandLine, code, stderr.String(), want)
}
}
}
// TestCheckCIDRTakesCIDR: check-cidr exits 0, writing nothing, for a
// CIDR the server takes in its TRUSTED_PROXIES.
func TestCheckCIDRTakesCIDR(t *testing.T) {
t.Parallel()
for _, cidr := range []string{"10.0.0.0/8", "192.0.2.1/32", "2001:db8::/32"} {
var stderr bytes.Buffer
code := cli.CheckCIDR(&stderr, cidr)
if code != 0 || stderr.Len() != 0 {
t.Errorf("%q: exit %d, stderr %q; want exit 0 and no output",
cidr, code, stderr.String())
}
}
}
// TestCheckCIDRRefusesOtherInput: check-cidr exits 1, with an error
// naming TRUSTED_PROXIES and the input, for anything the server would
// refuse in its TRUSTED_PROXIES, such as 1.2.3/32, which nginx would
// look up as a hostname.
func TestCheckCIDRRefusesOtherInput(t *testing.T) {
t.Parallel()
for _, cidr := range []string{
"1.2.3/32", "example.com/32", "10.0.0.0/33", "10.0.0.1", "",
} {
var stderr bytes.Buffer
code := cli.CheckCIDR(&stderr, cidr)
want := "TRUSTED_PROXIES " + strconv.Quote(cidr) + ": "
if code != 1 || !strings.HasPrefix(stderr.String(), want) {
t.Errorf("%q: exit %d, stderr %q; want exit 1 and %q...",
cidr, code, stderr.String(), want)
}
}
}
// TestPrepareDataDirTakesPathInVolume: prepare-data-dir exits 0,
// writing nothing, for a DATA_DIR below the volume, and creates it.
func TestPrepareDataDirTakesPathInVolume(t *testing.T) {
t.Parallel()
volume := t.TempDir()
dataDir := filepath.Join(volume, "reports")
var stderr bytes.Buffer
code := cli.PrepareDataDir(&stderr, volume, dataDir, currentUsername(t))
if code != 0 || stderr.Len() != 0 {
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
code, stderr.String())
}
info, err := os.Stat(dataDir)
if err != nil || !info.IsDir() {
t.Errorf("DATA_DIR not created: %v", err)
}
}
// TestPrepareDataDirRefusesPathOutsideVolume: prepare-data-dir exits
// 1, with an error naming DATA_DIR and its value, for a DATA_DIR
// outside the volume.
func TestPrepareDataDirRefusesPathOutsideVolume(t *testing.T) {
t.Parallel()
volume := t.TempDir()
var stderr bytes.Buffer
code := cli.PrepareDataDir(&stderr, volume, "/etc", currentUsername(t))
want := "DATA_DIR '/etc': " + reportbuf.ErrDataDirOutsideVolume.Error() + "\n"
if code != 1 || stderr.String() != want {
t.Errorf("exit %d, stderr %q; want exit 1 and %q",
code, stderr.String(), want)
}
}
// currentUsername is the name of the user the test runs as, the only
// owner a test not run as root can give files to.
func currentUsername(t *testing.T) string {
t.Helper()
current, err := user.Current()
if err != nil {
t.Fatal(err)
}
return current.Username
}
// failIfServerStarts is what the tests give Main in place of
// cli.RunServer: it fails the test instead of starting the server.
func failIfServerStarts(t *testing.T) func(appname, version string) {
t.Helper()
return func(string, string) {
t.Error("Main started the server")
}
}
+16
View File
@@ -0,0 +1,16 @@
package cli
import "io"
// CheckCIDR exposes the check-cidr subcommand to the external tests,
// which read what it writes to stderr.
func CheckCIDR(stderr io.Writer, cidr string) int {
return checkCIDR(stderr, cidr)
}
// PrepareDataDir exposes the prepare-data-dir subcommand to the
// external tests, which give it a volume and a user of their own in
// place of /data and netwatch.
func PrepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
return prepareDataDir(stderr, volume, dataDir, username)
}
+4 -4
View File
@@ -13,8 +13,8 @@ import (
"go.uber.org/fx"
)
// requireConfigError builds the config as main does and fails the
// test unless that fails with an error naming setting. It uses
// requireConfigError builds the config as cli.RunServer does and fails
// the test unless that fails with an error naming setting. It uses
// fx.New, because fxtest.New fails the test itself on an error.
func requireConfigError(t *testing.T, setting string) {
t.Helper()
@@ -31,8 +31,8 @@ func requireConfigError(t *testing.T, setting string) {
}
}
// requireTrustedProxies builds the config as main does and fails the
// test unless its TrustedProxies are want.
// requireTrustedProxies builds the config as cli.RunServer does and
// fails the test unless its TrustedProxies are want.
func requireTrustedProxies(t *testing.T, want []string) {
t.Helper()
+1 -1
View File
@@ -4,7 +4,7 @@ package globals
import "go.uber.org/fx"
//nolint:gochecknoglobals // set from main before fx starts
//nolint:gochecknoglobals // set by cli.RunServer before fx starts
var (
// Appname is the application name.
Appname string
+10 -2
View File
@@ -3,9 +3,17 @@ package handlers
import "net/http"
// HandleHealthCheck returns a handler for the health check
// endpoint.
// endpoint. It answers 200 while the status is "ok" and 503 otherwise,
// so that the image's HEALTHCHECK fails.
func (s *Handlers) HandleHealthCheck() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
s.respondJSON(w, r, s.hc.Healthcheck(), http.StatusOK)
resp := s.hc.Healthcheck()
code := http.StatusOK
if resp.Status != "ok" {
code = http.StatusServiceUnavailable
}
s.respondJSON(w, r, resp, code)
}
}
+22 -9
View File
@@ -9,17 +9,19 @@ import (
"testing"
"time"
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/handlers"
"sneak.berlin/go/netwatch/internal/healthcheck"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx/fxtest"
)
// newStartedHandlers builds Handlers with a real health check for the
// server named in g, and starts them, which records the time the
// uptime counts from.
// server named in g, and a report buffer in a temporary directory, and
// starts them, which records the time the uptime counts from.
func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Helper()
@@ -30,14 +32,23 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
t.Fatalf("logger: %v", err)
}
buf, err := reportbuf.New(lc, reportbuf.Params{
Config: &config.Config{DataDir: t.TempDir(), DataDirMaxBytes: 1 << 20},
Logger: log,
})
if err != nil {
t.Fatalf("report buffer: %v", err)
}
hc, err := healthcheck.New(lc,
healthcheck.Params{Globals: g, Logger: log})
healthcheck.Params{Buffer: buf, Globals: g, Logger: log})
if err != nil {
t.Fatalf("health check: %v", err)
}
h, err := handlers.New(lc,
handlers.Params{Globals: g, Healthcheck: hc, Logger: log})
h, err := handlers.New(lc, handlers.Params{
Buffer: buf, Globals: g, Healthcheck: hc, Logger: log,
})
if err != nil {
t.Fatalf("handlers: %v", err)
}
@@ -48,10 +59,12 @@ func newStartedHandlers(t *testing.T, g *globals.Globals) *handlers.Handlers {
return h
}
// TestHandleHealthCheck checks the health check's answer: 200, a JSON
// content type, and a JSON object with exactly the fields of
// healthcheck.HealthcheckResponse, carrying this server's name and
// version and an uptime counted from its start.
// TestHandleHealthCheck checks the health check's answer at start, when
// no write of a report file has failed: 200, a JSON content type, and a
// JSON object with exactly the fields of healthcheck.HealthcheckResponse,
// carrying "status":"ok", this server's name and version and an uptime
// counted from its start. The answer while writes fail is checked in
// internal/reportbuf, whose tests can make a write fail.
func TestHandleHealthCheck(t *testing.T) {
t.Parallel()
+10 -2
View File
@@ -10,6 +10,7 @@ import (
"sneak.berlin/go/netwatch/internal/config"
"sneak.berlin/go/netwatch/internal/globals"
"sneak.berlin/go/netwatch/internal/logger"
"sneak.berlin/go/netwatch/internal/reportbuf"
"go.uber.org/fx"
)
@@ -18,6 +19,7 @@ import (
type Params struct {
fx.In
Buffer *reportbuf.Buffer
Config *config.Config
Globals *globals.Globals
Logger *logger.Logger
@@ -68,12 +70,18 @@ func New(
return s, nil
}
// Healthcheck returns the current health status of the application.
// Healthcheck returns the current health status of the application:
// "ok", or "error" while report files cannot be written.
func (s *Healthcheck) Healthcheck() *HealthcheckResponse {
status := "ok"
if s.params.Buffer.LastWriteFailed() {
status = "error"
}
return &HealthcheckResponse{
Appname: s.params.Globals.Appname,
Now: time.Now().UTC().Format(time.RFC3339Nano),
Status: "ok",
Status: status,
UptimeHuman: s.uptime().String(),
UptimeSeconds: int64(s.uptime().Seconds()),
Version: s.params.Globals.Version,
+10
View File
@@ -255,6 +255,16 @@ func (b *Buffer) Append(v any) error {
return nil
}
// LastWriteFailed reports whether the last write of a report file to
// finish failed: false at start until a write fails, and false again
// once a write succeeds. While it is true, Append refuses reports.
func (b *Buffer) LastWriteFailed() bool {
b.mu.Lock()
defer b.mu.Unlock()
return b.writeErr != nil
}
// deleteOldestFiles deletes report files, oldest first, until n more
// bytes fit under maxBytes. It deletes none when the reports waiting
// to be written leave no room for n even with every file gone, since
+85 -7
View File
@@ -334,9 +334,9 @@ func stopWhileHeld(
return <-stopped
}
// startApp starts a Buffer through fx, as main does, with the DATA_DIR
// and DATA_DIR_MAX_BYTES the calling test has set, and returns it with
// the app, for tests that stop the app themselves.
// startApp starts a Buffer through fx, as cli.RunServer does, with the
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set, and returns
// it with the app, for tests that stop the app themselves.
func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
t.Helper()
@@ -358,8 +358,8 @@ func startApp(t *testing.T) (*reportbuf.Buffer, *fxtest.App) {
return buf, app
}
// startBuffer starts a Buffer through fx, as main does, with the
// DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
// startBuffer starts a Buffer through fx, as cli.RunServer does, with
// the DATA_DIR and DATA_DIR_MAX_BYTES the calling test has set.
func startBuffer(t *testing.T) *reportbuf.Buffer {
t.Helper()
@@ -1074,9 +1074,54 @@ func TestReportsRefusedWhileWritesFail(t *testing.T) {
}
}
// TestHealthCheckFailsWhileWritesFail asks the health check handler
// for its answer around a failed write. It answers 200 with
// "status":"ok" at start, 503 with "status":"error" from the failed
// write until a write succeeds, and 200 with "status":"ok" again after.
func TestHealthCheckFailsWhileWritesFail(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
buf, h := startBufferWithHandlers(t)
checkHealthCheck(t, h, "at start", http.StatusOK, "ok")
// A flush writes a file only when a report is waiting.
err := buf.Append(map[string]string{"id": "kept"})
if err != nil {
t.Fatalf("append: %v", err)
}
// With the data directory gone, the write has nowhere to go.
err = os.Remove(dir)
if err != nil {
t.Fatalf("remove data dir: %v", err)
}
err = buf.Flush()
if err == nil {
t.Fatal("flush succeeded with the data directory gone")
}
checkHealthCheck(t, h, "after the failed write",
http.StatusServiceUnavailable, "error")
err = os.Mkdir(dir, 0o750)
if err != nil {
t.Fatalf("make data dir: %v", err)
}
err = buf.Flush()
if err != nil {
t.Fatalf("flush with the data directory back: %v", err)
}
checkHealthCheck(t, h, "after a write succeeded", http.StatusOK, "ok")
}
// startBufferWithHandlers starts a Buffer and the Handlers that store
// reports in it through fx, as main does, with the DATA_DIR the calling
// test has set.
// reports in it through fx, as cli.RunServer does, with the DATA_DIR
// the calling test has set.
func startBufferWithHandlers(
t *testing.T,
) (*reportbuf.Buffer, *handlers.Handlers) {
@@ -1121,6 +1166,39 @@ func postReport(t *testing.T, h *handlers.Handlers, clientID string) int {
return rec.Code
}
// checkHealthCheck asks the health check handler for its answer, and
// fails the test, saying when it asked, unless the answer has status
// code wantCode and a "status" field of wantStatus.
func checkHealthCheck(
t *testing.T,
h *handlers.Handlers,
when string,
wantCode int,
wantStatus string,
) {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(t.Context(),
http.MethodGet, "/.well-known/healthcheck", http.NoBody)
h.HandleHealthCheck().ServeHTTP(rec, req)
var body struct {
Status string `json:"status"`
}
err := json.Unmarshal(rec.Body.Bytes(), &body)
if err != nil {
t.Fatalf("health check %s: body not JSON: %v (%q)",
when, err, rec.Body.String())
}
if rec.Code != wantCode || body.Status != wantStatus {
t.Fatalf("health check %s: %d with status %q, want %d with %q",
when, rec.Code, body.Status, wantCode, wantStatus)
}
}
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
// with room for exactly roomFor reports: exactly that many must be
// taken, which holds only if Append checks and counts each report
+3 -3
View File
@@ -22,9 +22,9 @@ import (
"go.uber.org/fx/fxtest"
)
// newServer builds a Server from the same constructors as main,
// configured from the environment. It is never started, so nothing
// listens.
// newServer builds a Server from the same constructors as
// cli.RunServer, configured from the environment. It is never started,
// so nothing listens.
func newServer(t *testing.T) *server.Server {
t.Helper()
+1 -1
View File
@@ -79,7 +79,7 @@ func New(
s.SetupRoutes()
s.httpServer = s.newHTTPServer()
go s.listenAndServe()
go func() { s.listenAndServe() }()
return nil
},
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "netwatch",
"version": "1.0.0",
"private": true,
"description": "Real-time network latency monitor SPA",
"type": "module",
"scripts": {
+4 -3
View File
@@ -10,9 +10,10 @@ main() {
cd "$ROOT"
"$ROOT/script/yarn" build
# VERSION comes from the environment. Unset or empty, it is git
# describe, or "dev" where there is no git or no repository history.
version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"
# VERSION comes from the environment. Unset or empty, it is
# `git describe --tags --always --dirty`, as in script/docker, or
# "dev" where there is no git or no repository history.
version="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.Version=$version" \