1 Commits
Author SHA1 Message Date
sneak 703e3e7c6e 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. 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 05:08:44 +00:00
20 changed files with 135 additions and 448 deletions
+1 -4
View File
@@ -73,12 +73,9 @@
# This repository's own entries, after the shared content above: the # This repository's own entries, after the shared content above: the
# frontend build, the viewport test's output, the binary `make build` # frontend build, the viewport test's output, the binary `make build`
# writes and the reports `make run` stores, and Go test binaries, # writes and the reports `make run` stores, and log files at the root.
# coverage output and log files at the root.
/dist /dist
/tmp /tmp
/netwatch-server /netwatch-server
/data /data
/*.test
/*.out
/*.log /*.log
+2 -3
View File
@@ -11,8 +11,7 @@ insert_final_newline = true
[Makefile] [Makefile]
indent_style = tab indent_style = tab
# This repository's own entries, after the shared content above.
[*.go] [*.go]
indent_style = tab indent_style = tab
# This repository's own sections, such as one for another language it
# uses, go below this comment, and a re-vendor keeps them.
-11
View File
@@ -1,20 +1,9 @@
name: check name: check
on: [push] on: [push]
# Free the shared runner: a new push cancels only the same branch's older run.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# Free the shared runner from a hung build.
timeout-minutes: 20
steps: steps:
# actions/checkout v4.2.2, 2026-02-22 # actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
# script/cibuild needs no token, so none is left in .git/config.
with:
persist-credentials: false
# All history and tags, so git describe finds the version tag.
fetch-depth: 0
- run: script/cibuild - run: script/cibuild
+2 -4
View File
@@ -27,7 +27,7 @@ node_modules/
# Environment files. `*.env` covers bare `.env` and the `prod.env` # Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are # convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds # re-included below. A repository that commits any other template adds
# its own negation at the end of this file, for example `!.env.example`. # its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV] *.[eE][nN][vV]
.[eE][nN][vV].* .[eE][nN][vV].*
.[eE][nN][vV][rR][cC] .[eE][nN][vV][rR][cC]
@@ -46,9 +46,7 @@ node_modules/
[iI][dD]_[eE][dD]25519 [iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK] [iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, such as its build outputs, go below # This repository's own entries, after the shared content above.
# this comment, and a re-vendor keeps them. Anchor a binary built at the
# root: `/myapp`, never `myapp`, which also ignores `cmd/myapp/`.
# Build output # Build output
dist/ dist/
-2
View File
@@ -25,8 +25,6 @@ linters:
# silenced by disabling that name, not by enabling the successor. # silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5 - wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2 - gomodguard # Deprecated, replaced by gomodguard_v2
# Misses findings at random in v2.14.0; back once a pinned release fixes it
- canonicalheader
settings: settings:
lll: lll:
line-length: 88 line-length: 88
+7 -17
View File
@@ -41,13 +41,11 @@ The scripts cover both the frontend and the Go backend, so `make check` fails if
either one is broken. We provide: either one is broken. We provide:
- `script/bootstrap` — install all dependencies (the pinned node via nvm unless - `script/bootstrap` — install all dependencies (the pinned node via nvm unless
the `node` on `PATH` has the pinned major version and is new enough for the one new enough for the frontend's dependencies is installed, yarn via
frontend's dependencies, yarn `YARN_VERSION` via corepack, or npm where there corepack, `yarn install --frozen-lockfile`, Go `GO_VERSION` unless the `go` on
is no corepack, unless the `yarn` on `PATH` is exactly that version, `PATH` is exactly that version, and the Go modules), linking what it installs
`yarn install --frozen-lockfile`, Go `GO_VERSION` unless the `go` on `PATH` is itself into `~/.local/bin`, which has to be on `PATH`. It installs no Go
exactly that version, and the Go modules), linking what it installs itself linter and not Docker: `make test` and `make lint` run in Docker
into `~/.local/bin`, which has to be on `PATH`. It installs no Go linter and
not Docker: `make test` and `make lint` run in Docker
- `script/setup` — make a fresh clone ready for development: bootstrap plus the - `script/setup` — make a fresh clone ready for development: bootstrap plus the
git pre-commit hook git pre-commit hook
- `script/dev` — run the Vite dev server, which proxies `/api` to a locally - `script/dev` — run the Vite dev server, which proxies `/api` to a locally
@@ -55,18 +53,11 @@ either one is broken. We provide:
- `script/build` — build the frontend for production into `dist/`, then the - `script/build` — build the frontend for production into `dist/`, then the
static `netwatch-server` binary into the repo root with its version stamped static `netwatch-server` binary into the repo root with its version stamped
in: `VERSION` from the environment, or when that is unset or empty, in: `VERSION` from the environment, or when that is unset or empty,
`git describe --tags --always --dirty` inside a git checkout, as `git describe` inside a git checkout, then `dev`
`script/docker` does, then `dev`
- `script/run` — build with `script/build`, then run `netwatch-server` with its - `script/run` — build with `script/build`, then run `netwatch-server` with its
defaults (see [Settings](#settings)) defaults (see [Settings](#settings))
- `script/clean` — remove what `script/build` writes - `script/clean` — remove what `script/build` writes
- `script/projectname` — print the project name (used for the Docker image tag) - `script/projectname` — print the project name (used for the Docker image tag)
- `script/yarn` — run yarn with the given arguments under the node
`script/bootstrap` would use: with `~/.local/bin` first on `PATH`, the first
`node` on that `PATH` when it has the pinned major version and is new enough
for the frontend's dependencies, and otherwise the pinned node in nvm. Every
script that runs yarn on the host runs it through this one, except
`script/bootstrap`, which picks its own node and yarn
- `script/test` — build the `test` phase of `Dockerfile`, writing no image: the - `script/test` — build the `test` phase of `Dockerfile`, writing no image: the
frontend's unit tests in its `frontend-test` stage, and the backend's Go tests frontend's unit tests in its `frontend-test` stage, and the backend's Go tests
with the race detector and coverage. The frontend's production build is left with the race detector and coverage. The frontend's production build is left
@@ -220,8 +211,7 @@ packages are in `internal/`:
- **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and - **`cli`**: Runs `netwatch-server` from its command line: the `check-cidr` and
`prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it `prepare-data-dir` subcommands, which `bin/entrypoint.sh` runs before it
starts the server, and, given no arguments, the server, with the `fx` wiring. starts the server, and otherwise the server, with the `fx` wiring.
It refuses any other arguments with a usage message and exit code 2.
- **`config`**: Loads configuration from environment variables and config files - **`config`**: Loads configuration from environment variables and config files
via Viper. via Viper.
- **`handlers`**: HTTP request handlers for the API (health check, report - **`handlers`**: HTTP request handlers for the API (health check, report
+9 -85
View File
@@ -9,12 +9,10 @@
# Status # Status
pre-1.0. Release candidates are tagged on `next`, the first being `v1.0.0-rc1`; pre-1.0. No git tags. `main` is the stable branch and `next` the development
`git tag --merged origin/next` lists them. `main` is the stable branch and branch, which every PR targets. The frontend and the Go backend ship as one
`next` the development branch, which every PR targets. The frontend and the Go Docker image, and the Gitea workflow `.gitea/workflows/check.yml` runs
backend ship as one Docker image, and the Gitea workflow `script/cibuild` on every push. Working toward 1.0.0.
`.gitea/workflows/check.yml` runs `script/cibuild` on every push, and cancels
the older run on the same branch. Working toward 1.0.0.
# Next Step # Next Step
@@ -23,36 +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: 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 - 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 package, `internal/cli`, as the Go style guide asks
([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs ([#160](https://git.eeqj.de/sneak/netwatch/issues/160)). `internal/cli` runs
@@ -61,55 +29,11 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
`Appname` and `Version` stay in `main`, where the build sets `Version`, and `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 `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 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 call `cli.Main` with `check-cidr` and a CIDR it takes and one it refuses, and
refuses, and check the exit code and what it writes to stderr, and with check the exit code, and with `prepare-data-dir` and a path outside `/data`,
`prepare-data-dir` and a path outside `/data`, and check that it exits 1 with and check that it exits 1 with the error from looking up the `netwatch` user,
the error from looking up the `netwatch` user, which the test image does not which the test image does not have. Others give each subcommand input it takes
have. Others give the functions behind the subcommands input they take and and input it refuses, and check the exit code and what it writes to stderr
input they refuse, and check the exit code and what they write to stderr
- 2026-10-19: when `VERSION` is unset or empty, `script/build` stamps the
version from `git describe --tags --always --dirty`, as `script/docker` does
([#156](https://git.eeqj.de/sneak/netwatch/issues/156)). Without `--tags` it
skipped lightweight tags, so `make run` could report a commit where the image
built from the same tree reported the tag. Checked once with a lightweight tag
on a scratch clone: `make build` stamped it
- 2026-10-19: `.dockerignore` keeps Go test binaries and coverage output at the
root out of the build context, with `/*.test` and `/*.out` in the repository's
own section ([#155](https://git.eeqj.de/sneak/netwatch/issues/155)). Such
files went into the build context of every `docker build` in `script/` and
were copied into the lint, test and build stages. Checked with a probe image
that copies the whole context: `x.test` and `x.out` planted at the root no
longer land in it, and nothing else changed. The entries match only at the
root, so the same files in a subdirectory still reach the image
- 2026-10-19: `.gitea/workflows/check.yml` is the sneak/prompts copy at
`0653579` ([#153](https://git.eeqj.de/sneak/netwatch/issues/153)). Its
checkout step sets `fetch-depth: 0`, so CI clones all history and tags, and
`git describe` in `script/cibuild` gives the image built from a tagged commit
the tag as its version; it gave the short commit. The checkout step also sets
`persist-credentials: false`, so the job's token is not left in `.git/config`.
A new push cancels the older run on the same branch, and a run stops after 20
minutes
- 2026-10-19: `script/bootstrap` keeps the `yarn` on `PATH` only when
`yarn --version` is exactly `YARN_VERSION`, and otherwise installs that
version as it did when no yarn was installed
([#154](https://git.eeqj.de/sneak/netwatch/issues/154)); any yarn used to be
kept. It uses the `node` on `PATH` only when that node has the major version
of `NODE_VERSION`, as `REPO_POLICIES.md` asks, and is at least
`NODE_MIN_VERSION`; a node 24 was kept before. After installing node or yarn
it looks the tool up through `PATH` again and stops if that one fails the same
check, as it does for Go, and it prints the node and yarn versions it uses.
Every script that runs yarn on the host, except `script/bootstrap`, which
picks its own node and yarn, runs it through the new `script/yarn`. That
script puts `~/.local/bin` first on `PATH`, uses the first `node` on that
`PATH` when it passes the same test, and otherwise runs yarn under the pinned
node in nvm; `make build` ran yarn under whatever node was first on `PATH`
- 2026-10-19: `bin/entrypoint.sh` has each of its steps in a function, and a
`main` at the end calls them in order, as the shell section of the code style
guide asks ([#163](https://git.eeqj.de/sneak/netwatch/issues/163)). It is
still POSIX sh with no `set -e`, and does what it did, with the same messages
and exit codes. Checked by running the image with each script: a normal start,
bad `PORT`, `TRUSTED_PROXIES` and `DATA_DIR` values, `docker stop`, INT, and
nginx exiting on its own
- 2026-10-19: while report files cannot be written, the health check answers 503 - 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` with `"status":"error"` and the same fields, so the image's `HEALTHCHECK`
reports the container `unhealthy` reports the container `unhealthy`
+63 -91
View File
@@ -13,27 +13,24 @@ set -u
# address and start anyway, and reports a bad port without naming # address and start anyway, and reports a bad port without naming
# PORT, so a value that is not a usable port stops the container here, # PORT, so a value that is not a usable port stops the container here,
# before either process starts. # before either process starts.
check_port() { export PORT="${PORT:-8080}"
export PORT="${PORT:-8080}" case "$PORT" in
case "$PORT" in *[!0-9]*)
*[!0-9]*) echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
echo "entrypoint: PORT must be a port number, not '$PORT'" >&2
exit 1
;;
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 exit 1
fi ;;
if [ "$PORT" -eq 8081 ]; then esac
echo "entrypoint: PORT cannot be 8081, netwatch-server" \ # The length is checked first because, for a number too big for it,
"listens there" >&2 # the shell's test prints an error and is false, so the range checks
exit 1 # alone would let it through.
fi 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
# TRUSTED_PROXIES names the reverse proxies in front of the container, # TRUSTED_PROXIES names the reverse proxies in front of the container,
# as IP addresses or CIDRs separated by commas. nginx takes the client # as IP addresses or CIDRs separated by commas. nginx takes the client
@@ -50,23 +47,21 @@ check_port() {
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one # TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
# below, naming the entry as written. set -f keeps a * in an entry from # below, naming the entry as written. set -f keeps a * in an entry from
# becoming a list of file names. # becoming a list of file names.
write_trusted_proxies() { TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}" set -f
set -f for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do case "$proxy" in
case "$proxy" in */*) cidr="$proxy" ;;
*/*) cidr="$proxy" ;; *:*) cidr="$proxy/128" ;;
*:*) cidr="$proxy/128" ;; *) cidr="$proxy/32" ;;
*) cidr="$proxy/32" ;; esac
esac if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then
if ! netwatch-server check-cidr "$cidr" 2> /dev/null; then echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \
echo "entrypoint: TRUSTED_PROXIES must be IP addresses or CIDRs" \ "separated by commas; '$proxy' is neither" >&2
"separated by commas; '$proxy' is neither" >&2 exit 1
exit 1 fi
fi echo "set_real_ip_from $cidr;"
echo "set_real_ip_from $cidr;" done > /etc/nginx/trusted-proxies.conf
done > /etc/nginx/trusted-proxies.conf
}
# netwatch-server keeps its report files in DATA_DIR, on the /data # netwatch-server keeps its report files in DATA_DIR, on the /data
# volume, which may be a host directory owned by root or by another # volume, which may be a host directory owned by root or by another
@@ -78,10 +73,12 @@ write_trusted_proxies() {
# naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it # naming DATA_DIR, unless DATA_DIR is /data or a path below it, and it
# acts on nothing outside /data, whatever symbolic or hard links it # acts on nothing outside /data, whatever symbolic or hard links it
# meets there. # meets there.
prepare_data_dir() { export DATA_DIR="${DATA_DIR:-/data/reports}"
export DATA_DIR="${DATA_DIR:-/data/reports}" netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
netwatch-server prepare-data-dir "$DATA_DIR" || exit 1
} # A stop signal is only noted here; the loop below acts on it.
stop_requested=""
trap 'stop_requested=yes' TERM INT
# netwatch-server runs as the netwatch user and listens on loopback # netwatch-server runs as the netwatch user and listens on loopback
# only, on a port other than the public one; configs/nginx.conf proxies # only, on a port other than the public one; configs/nginx.conf proxies
@@ -90,11 +87,9 @@ prepare_data_dir() {
# the container has. The netwatch user has no login shell, hence -s # the container has. The netwatch user has no login shell, hence -s
# /bin/sh. busybox su replaces itself with the command instead of # /bin/sh. busybox su replaces itself with the command instead of
# staying on as its parent, so $! is the server's own PID. # staying on as its parent, so $! is the server's own PID.
start_backend() { BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \
BIND_ADDRESS=127.0.0.1 PORT=8081 TRUSTED_PROXIES=127.0.0.1/32 \ su -s /bin/sh netwatch -c 'exec netwatch-server' &
su -s /bin/sh netwatch -c 'exec netwatch-server' & backend=$!
backend=$!
}
# nginx starts through the nginx image's own entrypoint, which applies # nginx starts through the nginx image's own entrypoint, which applies
# the image's start-up configuration and then replaces itself with # the image's start-up configuration and then replaces itself with
@@ -103,11 +98,9 @@ start_backend() {
# that rendering to PORT: a variable nginx itself uses, such as $uri, # that rendering to PORT: a variable nginx itself uses, such as $uri,
# would otherwise be replaced by an environment variable of the same # would otherwise be replaced by an environment variable of the same
# name. # name.
start_nginx() { NGINX_ENVSUBST_FILTER='^PORT$' \
NGINX_ENVSUBST_FILTER='^PORT$' \ /docker-entrypoint.sh nginx -g 'daemon off;' &
/docker-entrypoint.sh nginx -g 'daemon off;' & nginx=$!
nginx=$!
}
running() { running() {
kill -0 "$1" 2>/dev/null kill -0 "$1" 2>/dev/null
@@ -116,47 +109,26 @@ running() {
# POSIX sh cannot wait for whichever of two children exits first, so # POSIX sh cannot wait for whichever of two children exits first, so
# look once a second. The shell collects a child that has exited while # look once a second. The shell collects a child that has exited while
# it runs sleep, and running() is false for that child from then on. # it runs sleep, and running() is false for that child from then on.
wait_for_stop_or_exit() { while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do
while [ -z "$stop_requested" ] && running "$backend" && running "$nginx"; do sleep 1
sleep 1 done
done
}
# Stop both, then wait until neither is left. # Stop both, then wait until neither is left.
stop_both() { kill -TERM "$backend" "$nginx" 2>/dev/null
kill -TERM "$backend" "$nginx" 2>/dev/null while running "$backend" || running "$nginx"; do
while running "$backend" || running "$nginx"; do sleep 1
sleep 1 done
done
} wait "$backend"
backend_status=$?
wait "$nginx"
nginx_status=$?
echo "entrypoint: netwatch-server exited $backend_status," \
"nginx exited $nginx_status"
# Success is a requested stop that both processes exited cleanly from. # Success is a requested stop that both processes exited cleanly from.
exit_with_status() { if [ -n "$stop_requested" ] && [ "$backend_status" -eq 0 ] &&
wait "$backend" [ "$nginx_status" -eq 0 ]; then
backend_status=$? exit 0
wait "$nginx" fi
nginx_status=$? exit 1
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 "$@"
+13 -27
View File
@@ -1,7 +1,7 @@
// Package cli runs netwatch-server from its command line: the // Package cli runs netwatch-server from its command line: the
// check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh // check-cidr and prepare-data-dir subcommands, which bin/entrypoint.sh
// runs before it starts the server, and, given no arguments, the // runs before it starts the server, and otherwise the server, with the
// server, with the fx wiring. // fx wiring.
package cli package cli
import ( import (
@@ -22,21 +22,16 @@ import (
"go.uber.org/fx/fxevent" "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 // Main runs netwatch-server with args, its command line after the
// program's name, and returns the exit code. With no args, Main runs // program's name, and returns the exit code; the subcommands write
// the server by calling serve with appname and version, the name and // their errors to stderr. Unless args are "check-cidr CIDR" or
// version the server logs and reports. With "check-cidr CIDR" or // "prepare-data-dir DATA_DIR", Main runs the server by calling serve
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its // with appname and version, the name and version the server logs and
// errors to stderr. Any other args get a one-line usage message on // reports. main passes os.Stderr and RunServer; the tests pass a
// stderr and exit code 2. main passes os.Stderr and RunServer; the // buffer and a function that fails them, so that no test starts the
// tests pass a buffer and a function of their own, so that no test // server. When serve is RunServer and the server fails to start or to
// starts the server. When serve is RunServer and the server fails to // stop, fx's Run exits the process with status 1 and Main never
// start, to listen or to stop, fx's Run exits the process with status 1 // returns.
// and Main never returns.
func Main( func Main(
stderr io.Writer, stderr io.Writer,
appname, version string, appname, version string,
@@ -58,15 +53,6 @@ func Main(
return prepareDataDir(stderr, "/data", args[1], "netwatch") 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) serve(appname, version)
return 0 return 0
@@ -110,8 +96,8 @@ func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
// RunServer runs the server until it is told to stop, with appname and // 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 // 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 // fails to start or to stop, fx's Run exits the process with status 1
// with status 1 itself. // itself.
func RunServer(appname, version string) { func RunServer(appname, version string) {
globals.Appname = appname globals.Appname = appname
globals.Version = version globals.Version = version
+10 -77
View File
@@ -2,6 +2,7 @@ package cli_test
import ( import (
"bytes" "bytes"
"io"
"os" "os"
"os/user" "os/user"
"path/filepath" "path/filepath"
@@ -13,31 +14,18 @@ import (
"sneak.berlin/go/netwatch/internal/reportbuf" "sneak.berlin/go/netwatch/internal/reportbuf"
) )
// TestMainRunsCheckCIDR: Main given check-cidr returns 0, writing // TestMainRunsCheckCIDR: Main given check-cidr returns 0 for a CIDR the
// nothing, for a CIDR the server takes in its TRUSTED_PROXIES, and 1, // server takes in its TRUSTED_PROXIES and 1 for one it refuses, and
// with an error naming TRUSTED_PROXIES and the CIDR, for one it // does not start the server.
// refuses, and does not start the server.
func TestMainRunsCheckCIDR(t *testing.T) { func TestMainRunsCheckCIDR(t *testing.T) {
t.Parallel() t.Parallel()
var stderr bytes.Buffer for cidr, want := range map[string]int{"10.0.0.0/8": 0, "1.2.3/32": 1} {
code := cli.Main(io.Discard, "netwatch", "test",
code := cli.Main(&stderr, "netwatch", "test", []string{"check-cidr", cidr}, failIfServerStarts(t))
[]string{"check-cidr", "10.0.0.0/8"}, failIfServerStarts(t)) if code != want {
if code != 0 || stderr.Len() != 0 { t.Errorf("check-cidr %q: exit %d, want %d", cidr, code, want)
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)
} }
} }
@@ -61,61 +49,6 @@ func TestMainRunsPrepareDataDir(t *testing.T) {
} }
} }
// 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 // TestCheckCIDRTakesCIDR: check-cidr exits 0, writing nothing, for a
// CIDR the server takes in its TRUSTED_PROXIES. // CIDR the server takes in its TRUSTED_PROXIES.
func TestCheckCIDRTakesCIDR(t *testing.T) { func TestCheckCIDRTakesCIDR(t *testing.T) {
+1 -1
View File
@@ -79,7 +79,7 @@ func New(
s.SetupRoutes() s.SetupRoutes()
s.httpServer = s.newHTTPServer() s.httpServer = s.newHTTPServer()
go func() { s.listenAndServe() }() go s.listenAndServe()
return nil return nil
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "netwatch", "name": "netwatch",
"private": true, "version": "1.0.0",
"description": "Real-time network latency monitor SPA", "description": "Real-time network latency monitor SPA",
"type": "module", "type": "module",
"scripts": { "scripts": {
+2 -2
View File
@@ -23,8 +23,8 @@ main() {
"" | -* | *[[:space:]]*) usage ;; "" | -* | *[[:space:]]*) usage ;;
esac esac
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" add --dev "$1" yarn add --dev "$1"
"$ROOT/script/yarn" install --frozen-lockfile yarn install --frozen-lockfile
} }
main "$@" main "$@"
+14 -55
View File
@@ -3,14 +3,11 @@
# this repo. Idempotent: every install is guarded by a check so already # this repo. Idempotent: every install is guarded by a check so already
# installed tools are skipped. Base tooling comes from nix, apt, brew, # installed tools are skipped. Base tooling comes from nix, apt, brew,
# or apk (detected in that order); assumes nothing is present. Node is # or apk (detected in that order); assumes nothing is present. Node is
# used directly only if it has the major version of NODE_VERSION and is # used directly if it is at least NODE_MIN_VERSION; otherwise it is
# at least NODE_MIN_VERSION; otherwise NODE_VERSION is installed via nvm # installed at a pinned version via nvm (installing nvm itself first,
# (installing nvm itself first, from a hash-verified release archive, # from a hash-verified release archive, never curl | sh). Go, with its
# never curl | sh). Yarn is used directly only if it is exactly # gofmt, is used directly only if it is exactly GO_VERSION; otherwise
# YARN_VERSION; otherwise that version is prepared through corepack, # that release is installed from its hash-verified archive.
# or installed with npm where there is no corepack.
# Go, with its gofmt, is used directly only if it is exactly GO_VERSION;
# otherwise that release is installed from its hash-verified archive.
# #
# What this script installs outside the system package manager lives # What this script installs outside the system package manager lives
# under $HOME and is linked into ~/.local/bin, where make and the git # under $HOME and is linked into ~/.local/bin, where make and the git
@@ -145,63 +142,32 @@ ensure_nvm() {
rm -rf "$tmp" rm -rf "$tmp"
} }
# node_ok: the node on PATH has the major version of NODE_VERSION and is # node_ok: the node on PATH is at least NODE_MIN_VERSION. node itself
# at least NODE_MIN_VERSION. Only the major is compared with the pin, # compares the two: major, then minor, then patch.
# as REPO_POLICIES.md asks. node itself compares the versions: major,
# then minor, then patch. A node that fails does not pass.
# script/bootstrap and script/yarn carry this function unchanged.
node_ok() { node_ok() {
if ! command -v node >/dev/null 2>&1; then return 1; fi if missing node; then return 1; fi
node -e ' node -e '
const have = process.versions.node.split(".").map(Number); const have = process.versions.node.split(".").map(Number);
const want = process.argv[1].split(".").map(Number); const want = process.argv[1].split(".").map(Number);
const pinned = process.argv[2].split(".").map(Number);
if (have[0] !== pinned[0]) process.exit(1);
for (let i = 0; i < 3; i++) { for (let i = 0; i < 3; i++) {
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1); if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
} }
' "$NODE_MIN_VERSION" "$NODE_VERSION" ' "$NODE_MIN_VERSION"
} }
# ensure_node: unless node_ok, install NODE_VERSION and link its node. # ensure_node: unless node_ok, install NODE_VERSION and link its node.
# Afterwards node is looked up through PATH again and must pass node_ok.
ensure_node() { ensure_node() {
if node_ok; then if node_ok; then return 0; fi
echo "bootstrap: using node $(node --version)"
return 0
fi
ensure_nvm ensure_nvm
nvm_sh "nvm install $NODE_VERSION" nvm_sh "nvm install $NODE_VERSION"
link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node link_bin "$HOME/.nvm/versions/node/v$NODE_VERSION/bin/node" node
hash -r
if ! node_ok; then
echo "bootstrap: after installing node $NODE_VERSION through nvm," >&2
echo " the node on PATH, $(command -v node), is not it" >&2
exit 1
fi
echo "bootstrap: installed node $(node --version)"
} }
# yarn_ok: yarn --version on PATH prints exactly YARN_VERSION, compared # ensure_yarn: corepack writes its shims (pnpm and yarnpkg as well as
# over the whole version. A yarn that fails or prints anything else does # yarn) into $TOOLCHAIN rather than next to itself, and the npm fallback
# not pass. # installs there too; only yarn is linked.
yarn_ok() {
if missing yarn; then return 1; fi
version="$(yarn --version 2>/dev/null)" || return 1
[ "$version" = "$YARN_VERSION" ]
}
# ensure_yarn: unless yarn_ok, prepare YARN_VERSION and link its yarn.
# corepack writes its shims (pnpm and yarnpkg as well as yarn) into
# $TOOLCHAIN rather than next to itself, and the npm fallback installs
# there too; only yarn is linked. Afterwards yarn is looked up through
# PATH again and must pass yarn_ok, so another yarn that hides the link
# stops bootstrap.
ensure_yarn() { ensure_yarn() {
if yarn_ok; then if ! missing yarn; then return 0; fi
echo "bootstrap: using yarn $(yarn --version)"
return 0
fi
shims="$TOOLCHAIN/corepack-shims" shims="$TOOLCHAIN/corepack-shims"
mkdir -p "$shims" mkdir -p "$shims"
if ! missing corepack; then if ! missing corepack; then
@@ -216,13 +182,6 @@ ensure_yarn() {
shims="$TOOLCHAIN/npm-global/bin" shims="$TOOLCHAIN/npm-global/bin"
fi fi
link_bin "$shims/yarn" yarn link_bin "$shims/yarn" yarn
hash -r
if ! yarn_ok; then
echo "bootstrap: after installing yarn $YARN_VERSION in $shims," >&2
echo " the yarn on PATH, $(command -v yarn), does not report it" >&2
exit 1
fi
echo "bootstrap: installed yarn $(yarn --version)"
} }
# go_ok: the go on PATH has its gofmt beside it (a Go release ships the # go_ok: the go on PATH has its gofmt beside it (a Go release ships the
+4 -5
View File
@@ -8,12 +8,11 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" build yarn build
# VERSION comes from the environment. Unset or empty, it is # VERSION comes from the environment. Unset or empty, it is git
# `git describe --tags --always --dirty`, as in script/docker, or # describe, or "dev" where there is no git or no repository history.
# "dev" where there is no git or no repository history. version="${VERSION:-$(git describe --always --dirty 2>/dev/null || echo dev)}"
version="${VERSION:-$(git describe --tags --always --dirty 2>/dev/null || echo dev)}"
CGO_ENABLED=0 go build -trimpath \ CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X main.Version=$version" \ -ldflags "-s -w -X main.Version=$version" \
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" dev yarn dev
} }
main "$@" main "$@"
+1 -1
View File
@@ -10,7 +10,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" prettier --write '**/*.{js,css,html,md}' yarn prettier --write '**/*.{js,css,html,md}'
} }
main "$@" main "$@"
+1 -1
View File
@@ -7,7 +7,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
"$ROOT/script/yarn" prettier --check '**/*.{js,css,html,md}' yarn prettier --check '**/*.{js,css,html,md}'
} }
main "$@" main "$@"
+3 -5
View File
@@ -14,14 +14,12 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
main() { main() {
cd "$ROOT" cd "$ROOT"
NODE_OPTIONS=--test-reporter=dot \ NODE_OPTIONS=--test-reporter=dot timeout 90 yarn --silent run test || {
timeout 90 "$ROOT/script/yarn" --silent run test || {
echo "--- Rerunning with every test listed for details ---" echo "--- Rerunning with every test listed for details ---"
NODE_OPTIONS=--test-reporter=spec \ NODE_OPTIONS=--test-reporter=spec timeout 90 yarn --silent run test
timeout 90 "$ROOT/script/yarn" --silent run test
exit 1 exit 1
} }
timeout 30 "$ROOT/script/yarn" build timeout 30 yarn build
} }
main "$@" main "$@"
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# script/yarn: run yarn with the given arguments under the node
# script/bootstrap would use. Every script that runs yarn on the host
# runs it through this one, except script/bootstrap, which picks its
# own node and yarn.
set -eu
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
# Must match the pins in script/bootstrap.
NODE_VERSION="22.17.0"
NODE_MIN_VERSION="22.13.0"
# node_ok: the node on PATH has the major version of NODE_VERSION and is
# at least NODE_MIN_VERSION. Only the major is compared with the pin,
# as REPO_POLICIES.md asks. node itself compares the versions: major,
# then minor, then patch. A node that fails does not pass.
# script/bootstrap and script/yarn carry this function unchanged.
node_ok() {
if ! command -v node >/dev/null 2>&1; then return 1; fi
node -e '
const have = process.versions.node.split(".").map(Number);
const want = process.argv[1].split(".").map(Number);
const pinned = process.argv[2].split(".").map(Number);
if (have[0] !== pinned[0]) process.exit(1);
for (let i = 0; i < 3; i++) {
if (have[i] !== want[i]) process.exit(have[i] > want[i] ? 0 : 1);
}
' "$NODE_MIN_VERSION" "$NODE_VERSION"
}
# Run the yarn script/bootstrap installed: the yarn on PATH when the
# node on PATH passes node_ok, and otherwise yarn under the pinned node
# in nvm. nvm is a bash script, hence the subshell.
run_yarn() {
if node_ok && command -v yarn >/dev/null 2>&1; then
exec yarn "$@"
fi
if node_ok || [ ! -s "$HOME/.nvm/nvm.sh" ]; then
echo "script/yarn: no yarn; run script/bootstrap first" >&2
exit 1
fi
exec bash -c '. "$HOME/.nvm/nvm.sh" && nvm use "$1" >/dev/null &&
shift && exec yarn "$@"' bash "$NODE_VERSION" "$@"
}
main() {
cd "$ROOT"
# script/bootstrap links the node and yarn it installs into
# ~/.local/bin, which the shell that called it may not have on PATH.
PATH="$HOME/.local/bin:$PATH"
run_yarn "$@"
}
main "$@"