Files
nmon/scripts/gate-m3.sh
jleung f85c3ef781
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
M3: Go API per UI contract, embedded UI, openapi compat check, nmon-devtools, live gate + screenshots; M4 WP1 reachability and dependency suppression
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 20:34:14 +00:00

152 lines
7.9 KiB
Bash
Executable File

#!/usr/bin/env bash
# Gate M3: a port's in/out graph renders at 1 h, 24 h and 30 d, and a 48-port page renders in under 2 s.
# Procedure: docs/milestones/M3-graphs-and-ui.md.
# 1. make lint; unit tests of the UI server, the OpenAPI differ and the dev tools.
# 2. make build: the UI is built and embedded in the single static binary, which does not contain the dev tools.
# 3. nmon serve on a temp data dir (--poll-interval 10s) with `nmon-devtools seed-demo` (make devtools, a separate
# binary that is never shipped): simulated devices (the counters fixture, a 48-port switch, routers, a UPS, a
# 32-bit device) are added through the API and 31 days of synthetic samples are back-filled into hot; wait for
# real polls on top.
# 4. scripts/openapi-check.sh: no incompatible change since the last m*/v* tag, and openapi.json is current.
# 5. Contract drift: regenerate the web client from the live /openapi.json; npm run build must type-check.
# 6. Playwright against the real server: device page -> port graph -> 1 h -> 24 h -> 30 d with non-empty in and
# out series each; the 48-graph page under 2 s. Then live screenshots of every page into docs/screenshots/M3/.
# Needs: go, make, node 22+, npm, Playwright's chromium (cd web && npx playwright install chromium), curl, awk.
set -uo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
# Maintainability is a hard rule (docs/MAINTAINABILITY.md): no gate passes with lint findings.
make lint || { echo "FAIL: make lint"; exit 1; }
TMP="$(mktemp -d)"
FAILS=0
NMON_PID=""
SEED_PID=""
SHOTS="$ROOT/${NMON_SHOT_DIR:-docs/screenshots/M3}"
pass() { echo "PASS: $*"; }
fail() { echo "FAIL: $*"; FAILS=$((FAILS + 1)); }
die() { fail "$*"; exit 1; }
cleanup() {
[ -n "$SEED_PID" ] && kill "$SEED_PID" 2>/dev/null && wait "$SEED_PID" 2>/dev/null
if [ -n "$NMON_PID" ]; then kill -TERM "$NMON_PID" 2>/dev/null; wait "$NMON_PID" 2>/dev/null; fi
rm -rf "$TMP"
}
trap cleanup EXIT
trap 'exit 130' INT TERM
pick_port() { # a port free for both TCP and UDP
local p
while :; do
p=$((20000 + RANDOM % 30000))
ss -H -lntu 2>/dev/null | awk '{print $5}' | grep -q "[:.]$p\$" || { echo "$p"; return; }
done
}
# wait_for <seconds> <description> <command...>
wait_for() {
local n="$1" what="$2"; shift 2
for _ in $(seq 1 "$((n * 2))"); do "$@" && return 0; sleep 0.5; done
echo "timed out after ${n}s waiting for $what"; return 1
}
# 1. Unit tests of what this milestone adds on the Go side.
echo "== unit tests"
if go test -race -count=1 ./internal/webui/... ./internal/openapidiff/... ./cmd/nmon-devtools/... ./cmd/nmon/... >"$TMP/unit.log" 2>&1; then
pass "go test -race: webui, openapidiff, nmon-devtools, nmon"
else
tail -n 30 "$TMP/unit.log"; fail "go test -race"
fi
# 2. One static binary with the UI inside, and no dev tools.
echo "== make build (UI embedded)"
make build >"$TMP/build.log" 2>&1 || { tail -n 30 "$TMP/build.log"; die "make build"; }
strings -a ./nmon | grep -q 'seed-demo' && fail "the shipped nmon contains the dev tools" || pass "the shipped nmon does not contain the dev tools"
make devtools >"$TMP/devtools.log" 2>&1 || { tail -n 30 "$TMP/devtools.log"; die "make devtools"; }
file ./nmon | grep -q 'statically linked' && pass "nmon is a static binary" || fail "nmon is not statically linked"
ls internal/webui/dist/assets/*.js >/dev/null 2>&1 && pass "UI build embedded: $(ls internal/webui/dist/assets | wc -l) asset files" ||
fail "no UI build in internal/webui/dist (make web)"
# 3. Server + seed.
echo "== nmon serve + seed-demo"
PORT="$(pick_port)"
URL="http://127.0.0.1:$PORT"
DATA="$TMP/data"
./nmon serve --data-dir "$DATA" --secret-key-file "$TMP/secret.key" --insecure-http --listen "127.0.0.1:$PORT" \
--poll-interval 10s --trap-listen '' >"$TMP/nmon.log" 2>&1 &
NMON_PID=$!
up() { curl -fsS "$URL/api/v1/devices" >/dev/null 2>&1; }
wait_for 60 "nmon to serve" up || { tail -n 20 "$TMP/nmon.log"; die "nmon serve did not start"; }
HOT="$(grep -o 'hot=http://[^ ]*' "$TMP/nmon.log" | head -n 1 | cut -d= -f2)"
[ -n "$HOT" ] || die "no hot store URL in the serve log"
pass "nmon serving at $URL (hot $HOT)"
curl -fsS "$URL/" | grep -q 'id="root"' && pass "GET / serves the embedded SPA" || fail "GET / does not serve the SPA"
curl -fsS "$URL/devices/anything" | grep -q 'id="root"' && pass "client routes fall back to index.html" || fail "history fallback"
[ "$(curl -s -o /dev/null -w '%{http_code}' "$URL/api/v1/nope")" = 404 ] && pass "unknown /api paths do not fall back to the SPA" || fail "/api/v1/nope"
curl -fsSI "$URL/" | grep -qi '^cache-control: no-cache' && pass "index.html is no-cache" || fail "index.html cache-control"
ASSET="$(curl -fsS "$URL/" | grep -o '/assets/[^"]*\.js' | head -n 1)"
curl -fsSI "$URL$ASSET" | grep -qi 'immutable' && pass "hashed assets are immutable" || fail "asset cache-control"
./nmon-devtools seed-demo --api "$URL" --hot-url "$HOT" --env-out "$TMP/env" >"$TMP/seed.log" 2>&1 &
SEED_PID=$!
seeded() { grep -q 'seed-demo ready' "$TMP/seed.log"; }
wait_for 480 "seed-demo (devices added, 31 days back-filled)" seeded || { tail -n 30 "$TMP/seed.log"; die "seed-demo"; }
sed -n 's/^/ /p' "$TMP/seed.log"
set -a; . "$TMP/env"; set +a
[ -n "${NMON_DEVICE:-}" ] && [ -n "${NMON_DEVICE_48:-}" ] || die "seed-demo did not report the counters and 48-port devices"
pass "seeded: counters device $NMON_DEVICE, 48-port device $NMON_DEVICE_48"
# Real polls on top of the history: every simulated device answers and keeps being polled.
polled() { # two polls after the back-fill: nmon_snmp_up present for every added device
local n; n="$(curl -fsS -G "$HOT/api/v1/query" --data-urlencode 'query=count(nmon_snmp_up == 1)' | grep -o '"value":\[[^]]*\]' | sed 's/.*,"\([0-9]*\)".*/\1/')"
[ "${n:-0}" -ge 8 ]
}
wait_for 90 "devices to answer polls" polled && pass "live polls running on top of the back-filled history" || fail "live polls"
sleep 25
# 4. OpenAPI compatibility.
echo "== openapi"
curl -fsS "$URL/openapi.json" >"$TMP/openapi.live.json" || die "GET /openapi.json"
OPENAPI_NEW="$TMP/openapi.live.json" ./scripts/openapi-check.sh && pass "openapi-check" || fail "openapi-check"
if ./nmon openapi >"$TMP/openapi.cli.json" 2>/dev/null; then
cmp -s "$TMP/openapi.cli.json" openapi.json && pass "openapi.json matches nmon openapi" || fail "openapi.json is stale: run ./nmon openapi > openapi.json"
fi
# 5. Contract drift: the client generated from the live spec must still compile.
echo "== contract drift"
cp web/src/api/schema.d.ts "$TMP/schema.before.d.ts"
if (cd web && npm run gen:api -- "$URL/openapi.json" >"$TMP/genapi.log" 2>&1 && npm run build >"$TMP/webbuild.log" 2>&1); then
pass "web client generated from the live spec type-checks and builds"
else
tail -n 30 "$TMP/genapi.log" "$TMP/webbuild.log" 2>/dev/null; fail "web build against the live spec"
fi
cmp -s web/src/api/schema.d.ts "$TMP/schema.before.d.ts" && pass "web/src/api/schema.d.ts is current" ||
echo "NOTE: web/src/api/schema.d.ts changed after regeneration from the live spec; review and commit it"
(cd web && npm run lint >"$TMP/weblint.log" 2>&1) && pass "web lint after regeneration" || { tail -n 20 "$TMP/weblint.log"; fail "web lint after regeneration"; }
# 6. Browser journey and screenshots against the real server.
echo "== playwright (live)"
export NMON_URL="$URL"
if (cd web && npm run e2e:live -- gate.spec.ts) >"$TMP/e2e.log" 2>&1; then
pass "port graph renders in and out at 1 h, 24 h, 30 d; 48-port page under 2 s"
else
tail -n 40 "$TMP/e2e.log"; fail "playwright gate journey"
fi
sed -n 's/^/ /p' "$TMP/e2e.log" | grep -E "✓|✘|passed|failed|ok " | head -n 10
echo "== screenshots"
rm -rf "$SHOTS"; mkdir -p "$SHOTS"
if (cd web && NMON_SHOT_DIR="$SHOTS" npm run e2e:live -- screenshots.spec.ts) >"$TMP/shots.log" 2>&1; then
N="$(ls "$SHOTS"/*.png 2>/dev/null | wc -l)"
[ "$N" -ge 15 ] && pass "$N screenshots in $SHOTS" || fail "only $N screenshots in $SHOTS"
ls "$SHOTS"
else
tail -n 40 "$TMP/shots.log"; fail "screenshots"
fi
if [ "$FAILS" -eq 0 ]; then echo "GATE M3: PASS"; else echo "GATE M3: FAIL ($FAILS)"; exit 1; fi