Skip to content

Commit 4b2adaf

Browse files
cansofgreaseclaude
andcommitted
Expand the automated test pipeline
Every leg here covers something that could previously fail only in a user's hands, ordered by the blind spot it closes. The test databases were all in-memory, and in-memory SQLite pins its pool to one connection - so the entire class of multi-connection failures a real deployment can hit was invisible to every test run; it is exactly how a bug that dropped completed measurements passed the whole suite. An environment switch now redirects the test databases to real files, and CI reruns the database-heavy packages that way against the daemon's real four-connection pool. The hardware this daemon most often runs on - Raspberry Pi class ARM Linux - was built in CI but never executed there; the native test job now includes an ARM Linux runner running the full suite and boot smoke. The deep-test workflow - the real service lifecycle on Windows, macOS and Linux - only ran when someone remembered to dispatch it; it now also runs every Saturday. The release binaries users actually download were never executed before publish: a new job builds the snapshot artifacts with the release's own pinned goreleaser, asserts the version stamp landed, boots the Linux binary, then installs the deb the way a Debian user does - service account created, unit auto-started, daemon provably de-rooted with only the raw-socket capability - and removes it cleanly; the rpm installs and runs in a Rocky Linux container. A downgrade gate makes the previous released binary open a database created by the current commit, so a user who upgrades, hits trouble, and steps back is not stranded - the existing migration tests only ever looked forward. A weekly canary runs the full suite and the fleet probe against the NEXT Go release while it is still a candidate, so the next toolchain upgrade's evidence accumulates during the rc window instead of compressing into a deadline after security support has already lapsed. A weekly race-stress leg runs the race detector five times over the concurrency-heavy packages and again against real database files - two real races this month were one-in-many events a single pass cannot reliably catch. A weekly fuzz run finally gives real mutation time to the parsers that eat input this daemon does not control: the existing RDNS and ASN targets, plus new targets for the iperf3 server address an operator types, the JSON another program (iperf3) prints - injected through the same exec seam production output arrives through - and the sealed-passwords value that arrives in imported backups. And a browser smoke closes the one gap the frontend suite structurally cannot: it proves the page's functions, but nothing proved the page PAINTS. A real Chromium now loads the dashboard from a live daemon and asserts the floor - panels and chart render, no console errors, no failed requests - with deliberately coarse anchors, because fine-grained selectors rot into flakes. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent d8fe73f commit 4b2adaf

9 files changed

Lines changed: 540 additions & 3 deletions

File tree

.github/workflows/ci.yml

Lines changed: 202 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,21 @@ jobs:
5757
# access they were written to catch when the race detector is on.
5858
run: go test ./... -count=1 -race
5959

60+
- name: test (file-backed store)
61+
# The ordinary run's ":memory:" stores pin the SQLite pool to ONE
62+
# connection, which makes every multi-connection WAL failure mode
63+
# structurally invisible - the snapshot-upgrade class busy_timeout
64+
# cannot absorb (SQLITE_BUSY_SNAPSHOT) shipped a measurement-loss bug
65+
# past this entire matrix exactly that way. This leg reruns the
66+
# database-heavy packages against real files and the production
67+
# four-connection pool (see PINGULARITY_TEST_DB_DIR in store.go).
68+
# speedtest is excluded deliberately: its wall clock is capture
69+
# windows, not database work, and its file-backed coverage rides its
70+
# own concurrency hammer test.
71+
env:
72+
PINGULARITY_TEST_DB_DIR: ${{ runner.temp }}
73+
run: go test ./internal/store/ ./internal/settings/ . -count=1
74+
6075
- name: ui test
6176
run: node --test internal/web/ui/*.test.mjs
6277

@@ -121,7 +136,7 @@ jobs:
121136
- name: vet
122137
run: go vet ./...
123138

124-
# Native run: macOS + Windows runners EXECUTE the OS-tagged tests (which xbuild
139+
# Native run: macOS, Windows and arm64-Linux runners EXECUTE the OS-tagged tests (which xbuild
125140
# only cross-compiles - trace_darwin/windows, resolver, netstat, disk_free, the
126141
# Windows DACL), run the frontend tests, and smoke-boot the binary. The two
127142
# privileged paths - raw-socket traceroute and service install - are exercised
@@ -131,7 +146,10 @@ jobs:
131146
strategy:
132147
fail-fast: false
133148
matrix:
134-
os: [macos-latest, windows-latest]
149+
# ubuntu-24.04-arm is the closest CI gets to the Raspberry Pi class
150+
# this daemon most often runs on: before it, linux/arm64 was BUILT here
151+
# but never EXECUTED anywhere in the pipeline.
152+
os: [macos-latest, windows-latest, ubuntu-24.04-arm]
135153
runs-on: ${{ matrix.os }}
136154
steps:
137155
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
@@ -168,6 +186,49 @@ jobs:
168186
169187
# Validate the release config so a broken .goreleaser.yaml is caught on a PR
170188
# instead of at tag-push time (when it would abort a release mid-flight). Pinned
189+
# Browser smoke: ui.test.mjs proves the page's FUNCTIONS; nothing proved the
190+
# page PAINTS. A real Chromium loads the dashboard from a live daemon and
191+
# asserts the floor - panels and chart render, zero console errors, zero
192+
# failed requests. Coarse anchors only; fine-grained selectors rot into
193+
# flakes. release.yml gates on this via its `ci` job like everything here.
194+
browser-smoke:
195+
runs-on: ubuntu-latest
196+
timeout-minutes: 15
197+
defaults:
198+
run:
199+
shell: bash
200+
steps:
201+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
202+
203+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
204+
with:
205+
go-version-file: go.mod
206+
207+
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
208+
with:
209+
node-version: '22'
210+
211+
- name: install playwright chromium
212+
run: |
213+
npm install --no-save playwright@1.55.0
214+
npx playwright install --with-deps chromium
215+
216+
- name: boot the daemon and drive the browser
217+
run: |
218+
go build -o smoke_bin .
219+
./smoke_bin run -listen 127.0.0.1:9109 -db "$RUNNER_TEMP/browser-smoke.db" &
220+
pid=$!
221+
ok=
222+
for i in $(seq 1 30); do
223+
if curl -fsS http://127.0.0.1:9109/healthz >/dev/null 2>&1; then ok=1; break; fi
224+
sleep 1
225+
done
226+
[ -n "$ok" ] || { echo "daemon did not come up"; exit 1; }
227+
node internal/web/ui/browser_smoke.mjs 9109
228+
rc=$?
229+
kill "$pid" 2>/dev/null || true
230+
exit $rc
231+
171232
# to the exact goreleaser version release.yml publishes with, so what CI checks is
172233
# what the release runs.
173234
goreleaser-check:
@@ -185,6 +246,145 @@ jobs:
185246
version: 'v2.17.0'
186247
args: check
187248

249+
# Artifact smoke: the one distribution class nothing else executes. The native
250+
# jobs test SOURCE builds and the docker job compiles its own binary - the
251+
# goreleaser artifacts users actually download (release ldflags, -s -w, the
252+
# stamped version) were never run before publish. This builds the snapshot
253+
# artifacts with the same pinned goreleaser the release uses, asserts the
254+
# version stamp landed, and boots the Linux binary until /healthz answers.
255+
# release.yml gates on this via its `ci` job, like every job in this file.
256+
artifact-smoke:
257+
runs-on: ubuntu-latest
258+
timeout-minutes: 10
259+
defaults:
260+
run:
261+
shell: bash
262+
steps:
263+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
264+
265+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
266+
with:
267+
go-version-file: go.mod
268+
269+
- name: build snapshot artifacts (binaries, archives, deb/rpm)
270+
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a # v6.4.0
271+
with:
272+
version: 'v2.17.0'
273+
# The docker pipeline has its own dedicated gate (the `docker` job);
274+
# everything publish-shaped is skipped, everything package-shaped is
275+
# built - the deb/rpm below are installed for real further down.
276+
args: release --snapshot --clean --skip=docker,publish,announce,homebrew,winget,sign,sbom
277+
278+
- name: smoke the Linux artifact (version stamp + boot)
279+
run: |
280+
bin=$(echo dist/pingularity_linux_amd64*/pingularity)
281+
v=$("$bin" version)
282+
echo "artifact reports: $v"
283+
# An unstamped artifact reports the source default; the ldflags path
284+
# is exactly what source-built test binaries never exercise.
285+
case "$v" in
286+
*SNAPSHOT*) ;;
287+
*) echo "version stamp missing - ldflags did not apply"; exit 1;;
288+
esac
289+
"$bin" run -listen 127.0.0.1:9100 -db "$RUNNER_TEMP/artifact-smoke.db" &
290+
pid=$!
291+
ok=
292+
for i in $(seq 1 30); do
293+
if curl -fsS http://127.0.0.1:9100/healthz >/dev/null 2>&1; then ok=1; break; fi
294+
sleep 1
295+
done
296+
kill "$pid" 2>/dev/null || true
297+
[ -n "$ok" ] || { echo "the shipped-shape binary did not serve /healthz within 30s"; exit 1; }
298+
echo "artifact smoke OK: stamped, booted, healthy"
299+
300+
- name: install the deb and run the service lifecycle
301+
# The packages were built on every push and installed by no one until a
302+
# user did it. This does what a Debian user does: install (postinstall
303+
# creates the service account and auto-starts the unit), verify the
304+
# daemon answers AND runs de-rooted as the dedicated account with only
305+
# CAP_NET_RAW, stop it, remove the package (preremove stops/disables).
306+
run: |
307+
sudo dpkg -i dist/pingularity_*_linux_amd64.deb
308+
ok=
309+
for i in $(seq 1 30); do
310+
if curl -fsS http://127.0.0.1:9000/healthz >/dev/null 2>&1; then ok=1; break; fi
311+
sleep 1
312+
done
313+
[ -n "$ok" ] || { echo "packaged service did not serve /healthz"; sudo systemctl status pingularity || true; sudo journalctl -u pingularity --no-pager | tail -30 || true; exit 1; }
314+
svcuser=$(ps -o user= -p "$(systemctl show -p MainPID --value pingularity)")
315+
[ "$svcuser" = "pingularity" ] || { echo "service runs as '$svcuser', want the dedicated 'pingularity' account"; exit 1; }
316+
sudo systemctl stop pingularity
317+
sudo dpkg -r pingularity
318+
systemctl is-active pingularity >/dev/null 2>&1 && { echo "service still active after package removal"; exit 1; }
319+
echo "deb lifecycle OK: installed, de-rooted, healthy, removed cleanly"
320+
321+
- name: install the rpm in a Rocky Linux container
322+
# No systemd in the container - the scriptlets are written to degrade
323+
# gracefully there (verified in packaging/postinstall.sh) - so this leg
324+
# asserts the rpm installs, the account is created, and the binary runs.
325+
run: |
326+
docker run --rm -v "$PWD/dist:/dist:ro" rockylinux:9 bash -ec '
327+
rpm -i /dist/pingularity_*_linux_amd64.rpm
328+
getent passwd pingularity >/dev/null
329+
pingularity version
330+
'
331+
echo "rpm install OK"
332+
333+
# Downgrade gate: the previous RELEASED binary must open a database created by
334+
# this commit's schema. Migration tests synthesize old schemas in-process; this
335+
# is the other direction with the real prior artifact - the safety net for a
336+
# user who upgrades, hits trouble, and steps back. Rehearsed against v0.70.1:
337+
# healthz answers and the log stays clean.
338+
downgrade:
339+
runs-on: ubuntu-latest
340+
timeout-minutes: 10
341+
defaults:
342+
run:
343+
shell: bash
344+
steps:
345+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
346+
347+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
348+
with:
349+
go-version-file: go.mod
350+
351+
- name: create a current-schema database
352+
run: |
353+
go build -o new_pingularity .
354+
./new_pingularity run -listen 127.0.0.1:9107 -db "$RUNNER_TEMP/downgrade.db" &
355+
pid=$!
356+
ok=
357+
for i in $(seq 1 30); do
358+
if curl -fsS http://127.0.0.1:9107/healthz >/dev/null 2>&1; then ok=1; break; fi
359+
sleep 1
360+
done
361+
kill "$pid" 2>/dev/null || true
362+
[ -n "$ok" ] || { echo "current build failed to create its own database"; exit 1; }
363+
364+
- name: previous release opens it
365+
env:
366+
GH_TOKEN: ${{ github.token }}
367+
run: |
368+
if ! gh release download --pattern '*linux_amd64.tar.gz' -D prev; then
369+
echo "no prior release to test against - skipping"
370+
exit 0
371+
fi
372+
tar xzf prev/*.tar.gz -C prev
373+
./prev/pingularity version
374+
./prev/pingularity run -listen 127.0.0.1:9108 -db "$RUNNER_TEMP/downgrade.db" > old.log 2>&1 &
375+
pid=$!
376+
ok=
377+
for i in $(seq 1 30); do
378+
if curl -fsS http://127.0.0.1:9108/healthz >/dev/null 2>&1; then ok=1; break; fi
379+
sleep 1
380+
done
381+
kill "$pid" 2>/dev/null || true
382+
[ -n "$ok" ] || { echo "the PREVIOUS release cannot open a database this commit creates - a user who downgrades is stranded"; tail -30 old.log; exit 1; }
383+
if grep -ciE 'panic' old.log >/dev/null 2>&1 && [ "$(grep -ciE 'panic' old.log)" -gt 0 ]; then
384+
echo "previous release panicked on the new schema:"; tail -30 old.log; exit 1
385+
fi
386+
echo "downgrade OK: previous release serves healthz on the new schema"
387+
188388
# Image gate: builds BOTH Dockerfiles from the exact context layout goreleaser
189389
# stages (linux/<arch>/pingularity, COPY'd via $TARGETPLATFORM) and proves the
190390
# three properties a broken image would otherwise first show in a user's

.github/workflows/deep-test.yml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,15 @@ name: Deep test
55
# restart -> uninstall), data-dir permissions, netinfo/traceroute paths, and the
66
# Docker image + the canonical compose flow. Dispatch after platform-sensitive
77
# changes; artifacts carry assertion output and diagnostic log tails per leg.
8-
on: workflow_dispatch
8+
on:
9+
workflow_dispatch:
10+
schedule:
11+
# Saturdays 05:37 UTC - a slot of its own, clear of ci.yml's Monday vuln
12+
# re-scan and the fleet probe's Thursday, so service-lifecycle drift is
13+
# never tangled with either's noise. Dispatch-only coverage rotted quietly:
14+
# the best platform validation in the pipeline only ran when someone
15+
# remembered to ask for it.
16+
- cron: '37 5 * * 6'
917

1018
permissions:
1119
contents: read

.github/workflows/fuzz.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: fuzz
2+
3+
# Scheduled fuzz time for every parser that eats input this daemon does not
4+
# control. The targets have always existed as seed-corpus unit tests (netinfo's
5+
# RDNS/ASN parsers) or do now (the iperf3 output and server-address parsers,
6+
# the sealed-servers unseal path) - but no pipeline ever gave them -fuzz time,
7+
# so they only ever replayed their own seeds. Each leg gets eight minutes of
8+
# real mutation weekly; a finding uploads its reproducer corpus as an artifact
9+
# and fails the run.
10+
on:
11+
workflow_dispatch:
12+
schedule:
13+
# Wednesdays 05:29 UTC - its own slot among Monday's vuln re-scan,
14+
# Tuesday's race stress, Thursday's fleet probe and Saturday's deep-test.
15+
- cron: '29 5 * * 3'
16+
17+
permissions:
18+
contents: read
19+
20+
jobs:
21+
fuzz:
22+
runs-on: ubuntu-latest
23+
timeout-minutes: 60
24+
strategy:
25+
fail-fast: false
26+
matrix:
27+
include:
28+
- pkg: ./internal/netinfo/
29+
target: FuzzCityFromRDNS
30+
- pkg: ./internal/netinfo/
31+
target: FuzzPickCymruASN
32+
- pkg: ./internal/speedtest/
33+
target: FuzzParseIperfServer
34+
- pkg: ./internal/speedtest/
35+
target: FuzzIperfOutput
36+
- pkg: ./internal/settings/
37+
target: FuzzUnsealServers
38+
steps:
39+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
40+
41+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
42+
with:
43+
go-version-file: go.mod
44+
45+
- name: fuzz ${{ matrix.target }}
46+
run: go test ${{ matrix.pkg }} -run '^$' -fuzz "^${{ matrix.target }}$" -fuzztime 8m
47+
48+
- name: upload reproducer corpus
49+
if: failure()
50+
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
51+
with:
52+
name: fuzz-corpus-${{ matrix.target }}
53+
path: '**/testdata/fuzz/${{ matrix.target }}/**'
54+
if-no-files-found: ignore
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
name: go-next canary
2+
3+
# Runs the full suite - and the fleet probe, the one test that talks to the
4+
# real world - against the NEXT Go release while it is still a release
5+
# candidate. The 1.27 upgrade compressed a day of validation into a deadline
6+
# because 1.25 had already lost security support by the time 1.27 shipped;
7+
# with this canary the same evidence accumulates for free during the rc
8+
# window, and "do the removed TLS defaults strand any real Ookla servers"
9+
# gets answered by Thursday's baseline vs Sunday's rc run instead of by an
10+
# urgent one-off.
11+
on:
12+
workflow_dispatch:
13+
schedule:
14+
# Sundays 06:11 UTC. Most weeks this exits early at the detection step -
15+
# a release candidate only exists for a couple of months per cycle.
16+
- cron: '11 6 * * 0'
17+
18+
permissions:
19+
contents: read
20+
21+
jobs:
22+
canary:
23+
runs-on: ubuntu-latest
24+
timeout-minutes: 60
25+
steps:
26+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
27+
28+
- uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5.6.0
29+
with:
30+
go-version-file: go.mod
31+
32+
- name: detect a newer Go release candidate
33+
id: rc
34+
run: |
35+
cur=$(sed -n 's/^go \([0-9]*\)\.\([0-9]*\).*/\1 \2/p' go.mod)
36+
rc=$(python3 - $cur <<'PYEOF'
37+
import json, re, sys, urllib.request
38+
cur = (int(sys.argv[1]), int(sys.argv[2]))
39+
data = json.load(urllib.request.urlopen('https://go.dev/dl/?mode=json&include=all'))
40+
for v in data: # feed is newest-first
41+
if v.get('stable'):
42+
continue
43+
m = re.match(r'go(\d+)\.(\d+)', v['version'])
44+
if m and (int(m.group(1)), int(m.group(2))) > cur:
45+
print(v['version'])
46+
break
47+
PYEOF
48+
)
49+
echo "version=$rc" >> "$GITHUB_OUTPUT"
50+
if [ -n "$rc" ]; then
51+
echo "release candidate available: $rc"
52+
else
53+
echo "no Go release candidate newer than go.mod - nothing to canary this week"
54+
fi
55+
56+
- name: full suite on the release candidate
57+
if: steps.rc.outputs.version != ''
58+
env:
59+
GOTOOLCHAIN: ${{ steps.rc.outputs.version }}
60+
run: |
61+
go version
62+
go test ./... -count=1
63+
64+
- name: fleet probe on the release candidate
65+
# The only leg that can answer "does the next toolchain still talk to
66+
# the real Ookla fleet" - compare against Thursday's stable baseline.
67+
if: steps.rc.outputs.version != ''
68+
env:
69+
GOTOOLCHAIN: ${{ steps.rc.outputs.version }}
70+
FLEET_PROBE: "1"
71+
run: go test ./internal/speedtest/ -run TestServerFleetProbe -v -count=1 -timeout 25m

0 commit comments

Comments
 (0)