milestone 11: systemd and docker packaging, operator and architecture docs, config and api reference, docs drift guards
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
# Milestone 11: packaging, ops and docs (PLAN Phase 10)
|
||||
|
||||
Goal: systemd unit, Dockerfile + compose, and the four docs (operator, architecture,
|
||||
config-reference, API) — the documented deployment must work end-to-end; docs are
|
||||
drift-guarded where a guard is cheap and honest.
|
||||
|
||||
## Rulings (binding)
|
||||
|
||||
1. **Layout.** `deploy/systemd/nxdns.service` + `deploy/systemd/sysusers.conf`;
|
||||
`deploy/docker/{Dockerfile,compose.yaml,.dockerignore}`; `docs/{operator.md,
|
||||
architecture.md,config-reference.md,api.md}`; `README.md` at the root (the repo has
|
||||
none; a portfolio repo needs a front door — short: what, why, quickstart, doc links).
|
||||
PLAN.md:236 sketches docs/ subdirectories; single files need no subdirectories.
|
||||
|
||||
2. **API docs = hand-written `docs/api.md` + drift test.** No renderer is vendored
|
||||
(redoc/scalar are exactly the dependency liability AGENTS.md refuses), and a
|
||||
build-time YAML parser for rendering is scope the yaml does not justify — the yaml
|
||||
itself is already served unauthenticated at `GET /api/openapi.yaml` (routes.zig:49)
|
||||
and is the exhaustive contract. `docs/api.md` gives human-readable orientation:
|
||||
auth model (cookie session, login flow), rate limiting, error envelope, SSE
|
||||
semantics, then one line per operation (method, path, auth, one-sentence purpose)
|
||||
and a pointer to the yaml for schemas. A drift test asserts every served route
|
||||
appears textually in api.md (mirror of openapi.zig:34's guard). This satisfies
|
||||
m8 ruling 3's deferred "docs/api rendering" as the engineering call: rendered =
|
||||
readable, guarded, in-repo; not = a vendored JS bundle.
|
||||
|
||||
3. **Docs drift guards.** New `src/docs_drift_test.zig` (ORCHESTRATOR-owned, written
|
||||
after the doc sessions land): (a) every route in `router.routes` appears in
|
||||
docs/api.md; (b) every settings key from `model.toSettings` (the 44 keys) appears
|
||||
in docs/config-reference.md; (c) every CLI subcommand name appears in
|
||||
docs/operator.md. Docs embedded via a `docs_files` anonymous import added in
|
||||
build.zig (test_fixtures pattern, build.zig:61). Guards are textual-containment
|
||||
only — cheap, zero false authority.
|
||||
|
||||
4. **systemd unit.** `Type=simple` (no forking, shutdown.zig:35 handles SIGTERM),
|
||||
`ExecStart=/usr/local/bin/nxdns run`, stderr → journald (logging.zig:301 already
|
||||
states this; `logging.output=stderr` stays the default). Static system user `nxdns`
|
||||
via `deploy/systemd/sysusers.conf` (`u nxdns - "nxdns DNS sinkhole"`), NOT
|
||||
DynamicUser — the TLS key must be chown-able to a stable uid ("TLS keys readable by
|
||||
service user only", PLAN §19). `StateDirectory=nxdns` (0700 matches cli.zig:226),
|
||||
`LogsDirectory=nxdns` (covers logging.output=file; the binary does not create the
|
||||
directory, logging.zig:502), `ConfigurationDirectory=nxdns`.
|
||||
`AmbientCapabilities=CAP_NET_BIND_SERVICE` + `CapabilityBoundingSet=` the same
|
||||
(port 53; 443/853 covered by the same cap). Hardening: `NoNewPrivileges=yes`,
|
||||
`ProtectSystem=strict`, `ProtectHome=yes`, `PrivateTmp=yes`, `PrivateDevices=yes`,
|
||||
`ProtectKernelTunables/Modules/Logs=yes`, `ProtectControlGroups=yes`,
|
||||
`ProtectClock=yes`, `ProtectHostname=yes`, `ProtectProc=invisible`,
|
||||
`RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX`, `RestrictNamespaces=yes`,
|
||||
`RestrictRealtime=yes`, `RestrictSUIDSGID=yes`, `LockPersonality=yes`,
|
||||
`MemoryDenyWriteExecute=yes` (static Zig binary, no JIT), `UMask=0077`,
|
||||
`SystemCallFilter=@system-service`, `SystemCallArchitectures=native`,
|
||||
`Restart=on-failure`, `RestartSec=2`. No `ReadWritePaths` beyond what
|
||||
StateDirectory/LogsDirectory grant. Validate with `systemd-analyze verify` if the
|
||||
build host has it; report honestly if not.
|
||||
|
||||
5. **Docker.** Multi-stage: builder stage only stages `ca-certificates` (upstream
|
||||
TLS verification rescans the system CA bundle, tls_client.zig:216 — a scratch
|
||||
image without a bundle breaks every DoH/DoT upstream); final `FROM scratch` with
|
||||
the static musl binary, `/etc/ssl/certs/ca-certificates.crt`, a nonroot numeric
|
||||
`USER 65532:65532`, `VOLUME /var/lib/nxdns`, `EXPOSE 53/udp 53/tcp 8080 443 853`,
|
||||
`ENTRYPOINT ["/nxdns"]`, `CMD ["run"]`. The binary is NOT built inside the
|
||||
Dockerfile (the repo builds it with zig; the Dockerfile COPYes
|
||||
`zig-out/cross/$TARGETARCH-…/nxdns` via a build arg or buildx TARGETARCH mapping —
|
||||
keep it working for both arches). compose.yaml: ports 53:53/udp+tcp and 8080:8080
|
||||
(443/853 commented), bind-mount `./etc-nxdns:/etc/nxdns:ro`, named volume for
|
||||
`/var/lib/nxdns`, `sysctls: net.ipv4.ip_unprivileged_port_start=0` so the nonroot
|
||||
user binds 53 (per-netns sysctl; documented), `restart: unless-stopped`. First
|
||||
boot needs a seeded `/etc/nxdns/config.zon` with a `default` group + one enabled
|
||||
upstream or the container exits 2 (bootstrap.zig:38, app.zig:93) — operator.md and
|
||||
a compose comment both say so. Do NOT point the host's resolv.conf at nxdns
|
||||
itself for the container's own lookups.
|
||||
|
||||
6. **CI.** One added job `docker` in ci.yml: after building the x86_64 exe
|
||||
(ReleaseSafe, with the SPA dist like the cross job), `docker build` the image and
|
||||
run a container smoke (seed a minimal config.zon; `nxdns version` + boot + one
|
||||
`dig`-equivalent via the test client or `curl` on 8080/api/health; SIGTERM 0).
|
||||
No registry push — no publish step exists anywhere in this repo or the infra
|
||||
repo's CI, and registry credentials are an infra decision outside this repo.
|
||||
Manual publishing to git.mial.net stays possible and is documented in operator.md
|
||||
in one paragraph.
|
||||
|
||||
7. **Docs content contracts.**
|
||||
- operator.md: install (systemd path and docker path, both complete), first boot +
|
||||
config.zon seeding semantics (file seeds DB once, DB is truth thereafter,
|
||||
bootstrap.zig:38), auth setup (`web.password` hashed on import, never stored,
|
||||
export writes ""), TLS cert/key provisioning + 0600 expectations + the reload
|
||||
API/watcher, backup/restore = `nxdns export`/`import --force` (+ the 0600 export
|
||||
mode and why), upgrades (schema migration = install + restart, PLAN §20.11),
|
||||
data-dir layout table, exit codes (0/1/2/64, cli.zig:35), CLI reference (all six
|
||||
subcommands + flags), troubleshooting (exit 2 causes, `nxdns check` semantics
|
||||
incl. source-selection order cli.zig:511, disk-full degradation, port 53
|
||||
conflicts with systemd-resolved — include the disable recipe).
|
||||
- architecture.md: module map (the src/ inventory), the purity rule (dns/, filter/,
|
||||
local/, cache/ take bytes, no Io — AGENTS.md), std.Io injection + Threaded
|
||||
backend, data flow for one query (listener → handler → filter/cache/local →
|
||||
upstream → sink/logger), storage split (config.db truth / querylog.db expendable),
|
||||
web stack (std.http over TLS optional, SPA embedded via web_assets, SSE), cert
|
||||
hot-reload design (refcounted CertStore), failure-visibility doctrine (counters +
|
||||
/metrics over log spam). Concise — a map, not a novel.
|
||||
- config-reference.md: complete — every section/field with type, default, unit,
|
||||
validation range, and which subsystem consumes it; collections with required
|
||||
fields; DB-vs-file truth explanation; the `web.password`/`password_hash`
|
||||
exclusivity; `logging.level=.err` serializes as "error" (model.zig:154). The
|
||||
explorer inventory in this milestone's research is the skeleton; verify against
|
||||
model.zig/validate.zig while writing, do not trust the summary blindly.
|
||||
- api.md: per ruling 2.
|
||||
- README.md: ≤120 lines; what nxdns is, feature list (honest, shipping features
|
||||
only), quickstart (docker compose path), build-from-source (zig build, node for
|
||||
the SPA), doc links, license note if a LICENSE exists (do not invent one).
|
||||
|
||||
8. **No new runtime code.** This milestone adds zero behavior to the binary. The only
|
||||
src/ change is the orchestrator's docs_drift_test.zig + its build wiring. If a doc
|
||||
session finds a bug while documenting, it REPORTS it (no fix); the orchestrator
|
||||
triages.
|
||||
|
||||
9. **Pi 5 end-to-end**: the exit criterion runs on hardware this environment does not
|
||||
have. The deliverable here is: both suites green, docker smoke green on x86_64,
|
||||
`systemd-analyze verify` clean (or honestly reported unavailable), aarch64 binary
|
||||
built and statically verified (existing cross job). The operator doc's Pi 5 recipe
|
||||
is written to be executed by the user; the spec records this boundary explicitly.
|
||||
|
||||
## Sessions
|
||||
|
||||
U1, U3, U4, U5 parallel; U2 after U1 (documents the artifacts U1 produces);
|
||||
orchestrator wiring (ruling 3) after U3+U5.
|
||||
|
||||
## Session U1: deploy artifacts + CI
|
||||
|
||||
Owns `deploy/systemd/nxdns.service`, `deploy/systemd/sysusers.conf`,
|
||||
`deploy/docker/{Dockerfile,compose.yaml,.dockerignore}`, `.gitea/workflows/ci.yml`
|
||||
(one added job). Rulings 4, 5, 6. Verify: `systemd-analyze verify` (or report
|
||||
unavailable), local `docker build` + container smoke if the docker daemon is
|
||||
reachable (report honestly either way), `zig build test` untouched-green.
|
||||
|
||||
## Session U2: operator.md + README.md (after U1)
|
||||
|
||||
Owns `docs/operator.md`, `README.md`. Rulings 7 (operator + README). Reads U1's
|
||||
artifacts and the runtime/CLI facts from the code (verify against src/cli.zig,
|
||||
src/app.zig, src/config/bootstrap.zig — not from memory).
|
||||
|
||||
## Session U3: config-reference.md
|
||||
|
||||
Owns `docs/config-reference.md`. Ruling 7. Source of truth: src/config/model.zig +
|
||||
validate.zig + import/export/bootstrap. Every field, no sampling.
|
||||
|
||||
## Session U4: architecture.md
|
||||
|
||||
Owns `docs/architecture.md`. Ruling 7. Reads module headers; no deep dives needed
|
||||
beyond what the doc claims.
|
||||
|
||||
## Session U5: api.md
|
||||
|
||||
Owns `docs/api.md`. Rulings 2, 7. Source of truth: src/web/routes.zig (the served
|
||||
table: method, path, auth, limiter) + openapi.yaml summaries + auth.zig/sse.zig for
|
||||
the auth and SSE prose. Every route, no sampling.
|
||||
|
||||
## As built
|
||||
|
||||
**U1** delivered per rulings 4-6 with accepted deviations: `StateDirectoryMode=0700`
|
||||
(systemd defaults 0755; the binary cannot tighten a pre-existing directory) and
|
||||
`User=nxdns`/`Group=nxdns` added to the unit; the Dockerfile's builder stage also maps
|
||||
buildx TARGETARCH → cross-target dir and pre-chowns `/var/lib/nxdns` to 65532 (a named
|
||||
volume seeded from a root-owned image dir would be unwritable on first boot); compose
|
||||
gained a `build:` block; `deploy/docker/.dockerignore` is documentation-grade under
|
||||
BuildKit (only a root `.dockerignore` or `Dockerfile.dockerignore` is honored — the
|
||||
file's header says so). Verified: `systemd-analyze verify` clean modulo the off-host
|
||||
ExecStart path (an ExecStart=/bin/true copy verifies exit 0); full local docker build +
|
||||
smoke passed (binds 53 as uid 65532, /api/health ok, SIGTERM exit 0). The CI docker
|
||||
job probes both 127.0.0.1 and the container IP to survive either runner topology.
|
||||
Compose expects the operator-created seed at `deploy/docker/etc-nxdns/config.zon`
|
||||
(minimal: a `default` group + one enabled upstream), else exit 2.
|
||||
|
||||
**U2** delivered docs/operator.md (397 lines; systemd + docker + Pi 5 recipes, seeding
|
||||
semantics, auth, TLS, backup/restore, data-dir table, full CLI reference, exit codes,
|
||||
troubleshooting incl. the systemd-resolved DNSStubListener recipe) and README.md
|
||||
(72 lines, no license section — no LICENSE exists). All facts source-verified.
|
||||
|
||||
**U3** delivered docs/config-reference.md (12 scalar sections, 9 collections, DB-vs-file
|
||||
truth model, auth section, minimal + annotated examples) and surfaced five code
|
||||
discrepancies during writing (see fix wave below).
|
||||
|
||||
**U4** delivered docs/architecture.md (module map from the //! headers, purity rule
|
||||
with the honest exceptions, life-of-one-query pipeline verified against handler.zig,
|
||||
storage split, web stack, CertStore design, failure-visibility doctrine). Reported one
|
||||
stale comment (logging.zig:17 cited a moved cli.zig line) — orchestrator fixed the
|
||||
comment to cite start.zig:724 via std.process.Init.
|
||||
|
||||
**U5** delivered docs/api.md: all 56 operations (matches router.routes.len), auth /
|
||||
rate-limit / SSE prose, error envelope, openapi.yaml pointer. No code-vs-yaml
|
||||
discrepancies found.
|
||||
|
||||
**Orchestrator wiring (ruling 3)**: docs/docs.zig (embeds api.md, config-reference.md,
|
||||
operator.md), `docs_files` anonymous import on the test module in build.zig,
|
||||
src/docs_drift_test.zig with three containment guards (routes → api.md; toSettings
|
||||
keys → config-reference.md; the six subcommand names → operator.md), tests.zig import.
|
||||
|
||||
**Fix wave (orchestrator-triaged; ruling 8's no-runtime-code rule lifted for exactly
|
||||
these)** — U3's five discrepancies, triaged with stdlib evidence:
|
||||
1. `runtime.io_backend` DELETED end to end (model, settings handler + view, openapi,
|
||||
web types/SettingsPage/settingsDiff + tests, docs). Nothing consumed it — main uses
|
||||
init.io (stdlib Threaded, start.zig:724), and 0.16's std.Io.Evented has stubbed
|
||||
networking (Uring.zig netConnectIp → error.NetworkDown), so PLAN decision E's
|
||||
"io_uring via flag" is not deliverable at this tag. Re-add when std ships working
|
||||
evented net. Old DB rows warn-and-ignore via fromSettings' unknown-key path.
|
||||
2. `upstream.connect_timeout_ms` DELETED. No call site; the pool races the whole
|
||||
attempt against total_timeout (app.zig sets it from totalTimeout); Threaded panics
|
||||
on IpAddress.ConnectOptions.timeout != .none; std.http.Client has no knob. The
|
||||
validate cross-check is now total >= read only.
|
||||
3. `cache.size` KEPT: 0 is clean documented disabled behavior (put short-circuits;
|
||||
in-file test "a cache of zero entries stores nothing"). Doc row corrected.
|
||||
4. `dns.bind_ipv6` TIGHTENED: checkBind generalized to a BindFamily enum; dns.bind_ipv6
|
||||
requires an IPv6 literal (an IPv4 wildcard there made the v4 bind AddressInUse get
|
||||
swallowed with a false "dual-stack" log — silent IPv6 loss). web/TLS binds stay .any.
|
||||
5. doh/dot `readTimeout(.{})` sites KEPT: they are test fixtures; the real idle budget
|
||||
is the ruled 10s Options default. The doc claim was wrong and was removed.
|
||||
Settings key counts after deletion: toSettings emits 43 (incl. web.password_hash);
|
||||
the API-visible restart-required set is 42 (was 44).
|
||||
|
||||
## Review (Codex, as built)
|
||||
|
||||
Three rounds on one thread; round 3 returned "No findings."
|
||||
|
||||
Round 1 (5 important): stale-DB bind_ipv6 rows bypassed the new validate check at boot
|
||||
→ app.zig parseBind now enforces the IP family on both dns binds (BadBindAddress,
|
||||
exit 2, remedy in the message; the v4 side had the symmetric hole); app.zig's tests
|
||||
were not collected by tests.zig at all — the import line was added and the new test
|
||||
runs. operator.md's TLS recipe assumed the nxdns host user → Docker path now chowns
|
||||
65532:65532 numerically host-side (read-only bind mount). PLAN.md still promised the
|
||||
io_uring flag and connect_timeout_ms → synced (Io bullet records the drop with stdlib
|
||||
evidence; decision E row; example config). Drift guards were maskable → api.md guard
|
||||
anchors the full "| METHOD | `pattern` |" row per operation; operator.md guard anchors
|
||||
the "### `name" reference headings.
|
||||
|
||||
Round 2 (1 important, 1 minor): seed-permission guidance covered only web.password →
|
||||
now web.password or web.password_hash (a restored export); PLAN.md's "stubs all
|
||||
networking" overstated 0.16's Uring — now names the stubbed operations precisely.
|
||||
|
||||
Final gates: plain 1171/1284 passed, 113 skipped (integration-gated), 0 failed;
|
||||
integration 1280/1284, 4 skipped (live-network by design), 0 failed; cross ReleaseSafe
|
||||
with the SPA dist 18/18; web suite 121/121 with format/lint/typecheck clean.
|
||||
|
||||
## Module layout (new)
|
||||
|
||||
deploy/systemd/{nxdns.service,sysusers.conf}, deploy/docker/{Dockerfile,compose.yaml,
|
||||
.dockerignore}, docs/{operator,architecture,config-reference,api}.md, README.md,
|
||||
src/docs_drift_test.zig (orchestrator).
|
||||
|
||||
## File ownership
|
||||
|
||||
U1 deploy/* + ci.yml; U2 docs/operator.md + README.md; U3 docs/config-reference.md;
|
||||
U4 docs/architecture.md; U5 docs/api.md; orchestrator src/docs_drift_test.zig,
|
||||
build.zig (docs_files module), src/tests.zig.
|
||||
|
||||
## Acceptance (milestone complete)
|
||||
|
||||
- [ ] All files in the module layout exist with the ruling-7 content contracts met.
|
||||
- [ ] Docs drift tests pass: route coverage in api.md, settings-key coverage in
|
||||
config-reference.md, subcommand coverage in operator.md.
|
||||
- [ ] CI docker job builds the image and the container smoke passes (in CI; locally
|
||||
if the daemon is available).
|
||||
- [ ] `systemd-analyze verify deploy/systemd/nxdns.service` clean, or its
|
||||
unavailability recorded in the U1 report.
|
||||
- [ ] Both suites 0 failed; cross ReleaseSafe with the SPA dist green.
|
||||
- [ ] No runtime-behavior changes (git diff over src/ shows only docs_drift_test.zig
|
||||
+ wiring).
|
||||
|
||||
## Anti-requirements
|
||||
|
||||
- No vendored API-doc renderer (redoc/scalar/swagger-ui), no YAML parser.
|
||||
- No registry publish step; no k3s manifests (the infra repo owns deployment there).
|
||||
- No SIGHUP/reload feature, no env-var config, no new CLI flags — document what
|
||||
exists; report gaps instead of filling them.
|
||||
- No LICENSE invention; no badges or marketing prose in README.
|
||||
Reference in New Issue
Block a user