The cache snapshot never loaded, so every restart was a cold cache. Snapshot
packed the whole key while Load decoded the record header out of the key's
remainder instead of the value, so every entry was skipped. The two sides are
now an explicit encodeRecord/decodeRecord pair with a version byte and a CRC-32C
trailer, so a future format change is detected instead of silently misparsed.
Load also drops entries that are already past ExpiresAt and rewrites the TTL to
the time actually left, so a served TTL never jumps back up across a restart.
Blocklist, all six gaps the failing tests were pointing at: a leading UTF-8 BOM
is stripped, "!allow example.com" works, SourceMeta exists for a source that
only ever contributed a rule, Pattern() reports the bare name again (the new
WireName() produces the wildcard form for the API), an RPZ record with no
explicit TTL inherits the zone default instead of whatever the reader left in
the RR, a hosts line with no address in the first field says so, and the
adblock parser handles CRLF and adblock-flavoured lines pasted into a plain
list. TestHolderSwapIsAtomic was asserting the wrong invariant — the swap was
always atomic, a name blocked by the old set is legitimately unblocked by the
new one.
Tests added for acl and resolver, both of which turned up real bugs:
- a PTR suffix matched across label boundaries, so a group scoped to
example.com. also swallowed ample.com. and notexample.com. — a client could
land in a group it does not belong to;
- the "*" catch-all was dropped from the rendered domain patterns, so the
engine's per-query recompile lost the "everything" case;
- Upstream.Success never closed an open breaker, so a recovered upstream stayed
excluded for the full cooldown;
- Exchange fanned out in parallel even under policy: failover.
Deploy set: go.mod requires Go 1.27.1 but CI pinned 1.23 and the Dockerfile
pinned golang:1.23-alpine, so neither could have built. The Dockerfile also
copied the source tree after the console bundle, which clobbered it.
The k8s set asked for hostNetwork with two replicas, but the server never sets
SO_REUSEPORT, so the second pod could not bind :53 and would crash-loop. It now
runs without hostNetwork behind a LoadBalancer Service, with a separate
ClusterIP Service for the Ingress (a headless Service cannot back one), a
ServiceAccount, an API-key Secret, per-pod PVCs, and a kustomization. The HPA
is gone: it cannot drive a StatefulSet. To let the API key come from that
Secret, config.Load now expands ${VAR} / ${VAR:-default} / ${NAME-default} and
fails to start on an unset variable with no default, so a missing secret is a
crash loop rather than an unauthenticated console. Expansion skips YAML
comments, so documenting ${VAR} in the ConfigMap does not break the file.
Verified: gofmt, go build, go vet clean; go test -race ./internal/... green
(acl, app, blocklist, cache, config, resolver, zonefile).
207 lines
6.3 KiB
YAML
207 lines
6.3 KiB
YAML
name: ci
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
pull_request:
|
|
# A push to a fork's branch is the common case for a PR-based workflow, so
|
|
# run on any branch that has a PR rather than only on main.
|
|
workflow_dispatch:
|
|
|
|
permissions:
|
|
contents: read
|
|
|
|
concurrency:
|
|
group: ${{ github.workflow }}-${{ github.ref }}
|
|
cancel-in-progress: true
|
|
|
|
env:
|
|
# Must track the `go` directive in go.mod; an older runner fails every
|
|
# build with "go.mod requires go >= ...". The fmt check only has real teeth
|
|
# when the toolchain is new enough (gofmt is part of the Go toolchain in 1.27).
|
|
GO_VERSION: "1.27"
|
|
NODE_VERSION: "22"
|
|
|
|
jobs:
|
|
go:
|
|
name: Go build, vet and test
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-go@v5
|
|
with:
|
|
go-version: ${{ env.GO_VERSION }}
|
|
cache: true
|
|
|
|
# The placeholder keeps //go:embed valid when the console has not been
|
|
# built in this job, so the Go checks do not depend on the web toolchain.
|
|
- name: Ensure the embed directory exists
|
|
run: mkdir -p internal/web/dist && touch internal/web/dist/.gitkeep
|
|
|
|
- name: gofmt
|
|
run: |
|
|
unformatted=$(gofmt -l .)
|
|
if [ -n "$unformatted" ]; then
|
|
echo "gofmt needed on:"; echo "$unformatted"; exit 1
|
|
fi
|
|
|
|
- name: go vet
|
|
run: go vet ./...
|
|
|
|
# The race detector is not optional here: the whole design leans on
|
|
# atomic holders and swap hooks, and a regression there is silent at
|
|
# runtime and only shows up as a data race under load.
|
|
- name: go test -race
|
|
run: go test -race -count=1 ./...
|
|
|
|
- name: go test -short (no race, coverage)
|
|
run: go test -short -count=1 -coverprofile=coverage.out ./...
|
|
|
|
- name: Coverage summary
|
|
run: go tool cover -func=coverage.out | tail -20
|
|
|
|
- name: Build without the console
|
|
run: CGO_ENABLED=0 go build -trimpath -o /tmp/fancy-dns ./cmd/fancy-dns
|
|
|
|
web:
|
|
name: Console typecheck and build
|
|
runs-on: ubuntu-latest
|
|
defaults:
|
|
run:
|
|
working-directory: web
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: npm
|
|
cache-dependency-path: web/package-lock.json
|
|
|
|
- name: Install
|
|
run: npm ci --no-audit --no-fund
|
|
|
|
- name: Typecheck
|
|
run: npx tsc -b --noEmit
|
|
|
|
- name: Build
|
|
run: npm run build
|
|
|
|
- name: Assert the console landed where the Go embed looks
|
|
run: test -f ../internal/web/dist/index.html
|
|
|
|
binary:
|
|
name: Binary with the console embedded
|
|
runs-on: ubuntu-latest
|
|
needs: [go, web]
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: actions/setup-go@v5
|
|
with:
|
|
go-version: ${{ env.GO_VERSION }}
|
|
cache: true
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: ${{ env.NODE_VERSION }}
|
|
cache: npm
|
|
cache-dependency-path: web/package-lock.json
|
|
|
|
- name: Install console dependencies
|
|
working-directory: web
|
|
run: npm ci --no-audit --no-fund
|
|
|
|
- name: Build the console
|
|
working-directory: web
|
|
run: npm run build
|
|
|
|
- name: Build the binary
|
|
run: |
|
|
CGO_ENABLED=0 go build -trimpath \
|
|
-ldflags "-s -w -X git.k8s-git-k6n.net/mats/fancy-dns/internal/app.Version=${{ github.sha }}" \
|
|
-o fancy-dns ./cmd/fancy-dns
|
|
|
|
# A binary that starts, binds, and validates a config end to end: this
|
|
# catches a broken embed or a bad default that unit tests never exercise.
|
|
- name: Smoke test the real binary
|
|
run: |
|
|
set -euo pipefail
|
|
mkdir -p /tmp/smoke
|
|
# Unprivileged ports, and trust_loopback off: the example config trusts
|
|
# loopback, which would make the 401 assertion below pass for the
|
|
# wrong reason (the request would be authorised as a local caller).
|
|
sed \
|
|
-e 's#127.0.0.1:8080#127.0.0.1:18080#' \
|
|
-e 's#0.0.0.0:53#127.0.0.1:15353#g' \
|
|
-e 's#\[::\]:53#[::1]:15353#g' \
|
|
-e 's#trust_loopback: true#trust_loopback: false#' \
|
|
deploy/config.example.yaml > /tmp/smoke/config.yaml
|
|
|
|
./fancy-dns -config /tmp/smoke/config.yaml -check
|
|
|
|
./fancy-dns -config /tmp/smoke/config.yaml &
|
|
pid=$!
|
|
trap 'kill $pid 2>/dev/null || true' EXIT
|
|
for i in $(seq 1 40); do
|
|
curl -fsS http://127.0.0.1:18080/api/healthz >/dev/null 2>&1 && break
|
|
sleep 0.5
|
|
done
|
|
|
|
echo "--- health"
|
|
curl -fsS http://127.0.0.1:18080/api/healthz | tee /dev/stderr | grep -q '"status"'
|
|
|
|
echo "--- console is embedded"
|
|
curl -fsS http://127.0.0.1:18080/ | grep -qi '<!doctype html'
|
|
|
|
echo "--- unauthenticated admin API is refused"
|
|
code=$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:18080/api/whoami)
|
|
test "$code" = "401" || { echo "expected 401, got $code"; exit 1; }
|
|
|
|
echo "--- authenticated admin API works"
|
|
curl -fsS -H "X-API-Key: CHANGE-ME-long-random-string" \
|
|
http://127.0.0.1:18080/api/whoami | grep -q '"can_write":true'
|
|
|
|
echo "smoke test passed"
|
|
|
|
- name: Upload the binary
|
|
uses: actions/upload-artifact@v4
|
|
with:
|
|
name: fancy-dns-linux-amd64
|
|
path: fancy-dns
|
|
retention-days: 14
|
|
|
|
image:
|
|
name: Container image
|
|
runs-on: ubuntu-latest
|
|
needs: [binary]
|
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
permissions:
|
|
contents: read
|
|
packages: write
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
|
|
- uses: docker/setup-buildx-action@v3
|
|
|
|
- name: Log in to the registry
|
|
uses: docker/login-action@v3
|
|
with:
|
|
registry: ghcr.io
|
|
username: ${{ github.actor }}
|
|
password: ${{ secrets.GITHUB_TOKEN }}
|
|
|
|
- name: Build and push
|
|
uses: docker/build-push-action@v6
|
|
with:
|
|
context: .
|
|
push: true
|
|
tags: |
|
|
ghcr.io/${{ github.repository }}:latest
|
|
ghcr.io/${{ github.repository }}:${{ github.sha }}
|
|
build-args: |
|
|
VERSION=${{ github.sha }}
|
|
cache-from: type=gha
|
|
cache-to: type=gha,mode=max
|