Compare commits

..
Author SHA1 Message Date
clawbot 16c577d6d4 docker: set up the data directory in an entrypoint (closes #166)
check / check (push) Waiting to run
The runtime image no longer sets USER. Its new entrypoint,
deploy/docker-entrypoint.sh, runs as root: it creates the data
directory if needed, gives it and everything in it to the dnswatcher
user (uid 10001) with mode 700 on the directory, then runs dnswatcher
as that user with su-exec. A bind-mounted host directory, whether
empty and root-owned or holding a state file left by another uid, no
longer has to be chowned first, and the README's upaas section now says
only which path to mount. The startup check that the data directory is
writable stays.

Model: opus-5-5
2026-09-29 10:59:10 +00:00
11 changed files with 64 additions and 61 deletions
-4
View File
@@ -60,10 +60,6 @@ linters:
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/dnswatcher/internal/livednstest
desc: >-
Live-DNS test support belongs in test files and in packages
whose directory name ends in test, not in the shipped binary.
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
+8 -10
View File
@@ -41,18 +41,15 @@ RUN make build
# alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
RUN apk add --no-cache ca-certificates tzdata
RUN apk add --no-cache ca-certificates tzdata su-exec
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Run as an unprivileged user that owns the data directory. A fresh named
# volume inherits this ownership; a bind-mounted host directory must be
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
# fails.
# dnswatcher runs as this unprivileged user. The entrypoint creates the
# data directory and gives it to this user on every start.
RUN addgroup -S -g 10001 dnswatcher \
&& adduser -S -G dnswatcher -u 10001 dnswatcher \
&& mkdir -p /var/lib/dnswatcher \
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
&& adduser -S -G dnswatcher -u 10001 dnswatcher
ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
@@ -62,7 +59,8 @@ ENV DNSWATCHER_DATA_DIR=/var/lib/dnswatcher
# data directory, or the binary's directory, the working directory.
WORKDIR /
USER dnswatcher
# No USER: the entrypoint must start as root to set up the data
# directory; it then runs dnswatcher as the dnswatcher user.
EXPOSE 8080
@@ -72,4 +70,4 @@ EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null "http://127.0.0.1:${PORT:-8080}/.well-known/healthcheck" || exit 1
ENTRYPOINT ["/usr/local/bin/dnswatcher"]
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
+2 -12
View File
@@ -278,7 +278,7 @@ internal/
tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler
livednstest/livednstest.go Retry and concurrency limit for tests
livedns/livedns.go Retry and concurrency limit for tests
against live DNS (imported only by tests)
```
@@ -533,17 +533,7 @@ repository's `Dockerfile` and runs it. The app needs:
- **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
`prod` pull request is a deploy.
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
the state file lives. upaas bind-mounts the host path it is given and
does not create it. The container runs as uid 10001 and does not start
unless it can write there. Create the directory before the first
deploy:
```sh
mkdir -p /path/to/data
chown 10001:10001 /path/to/data
chmod 700 /path/to/data
```
the state file lives.
- **Network and port:** the dashboard is unauthenticated and shows every
watched name and recent alert, and upaas publishes every mapped port on
all interfaces of the host
+1 -1
View File
@@ -25,7 +25,7 @@ real servers ensures the resolver works correctly in production.
- Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
plus processing margin
- Root server fan-out is limited to reduce parallel query load
- Live lookups that expect an answer go through `internal/livednstest`,
- Live lookups that expect an answer go through `internal/livedns`,
which limits how many run at once in a test binary and retries a
lookup that got none
- Flaky failures from transient network issues are acceptable and
+7 -5
View File
@@ -19,9 +19,11 @@ Rationale, Design, TODO, License, Author) if any are still missing.
# Completed Steps
- 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and
added to the `test-support` `deny` list in `.golangci.yml`, so `make lint`
fails when program code imports it (closes #164).
- 2026-09-29: the image sets up its own data directory. Its entrypoint,
`deploy/docker-entrypoint.sh`, starts as root, creates the data directory if
needed, gives it and everything in it to the `dnswatcher` user with mode 700
on the directory, then runs dnswatcher as that user with `su-exec`. A
bind-mounted host directory no longer has to be chowned first (closes #166).
- 2026-09-29: `.golangci.yml` re-fetched unchanged from `sneak/prompts`. It
replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no
longer warns about it, and turns on `depguard` with the org `test-support`
@@ -33,8 +35,8 @@ Rationale, Design, TODO, License, Author) if any are still missing.
record and nameserver changes by preparing the saved state a check starts
from; the resolver timeout test queries an address that never answers, and
`NewFromLoggerWithClient`, used only by its stand-in client, is gone. The
live-DNS retry and concurrency limit moved to `internal/livednstest`, which
both test packages use. `TESTING.md` states the README's rule (closes #159).
live-DNS retry and concurrency limit moved to `internal/livedns`, which both
test packages use. `TESTING.md` states the README's rule (closes #159).
- 2026-09-28: the inconsistency alert is sent once, on the check where two
nameservers start to disagree or where a nameserver that disagrees first
appears, instead of on every check while they disagree, and not again after
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the Docker image's ENTRYPOINT. It runs as
# root only to give the data directory to the dnswatcher user: a host
# directory bind-mounted there keeps its host owner, often root, and may
# hold a state file left by another uid, which dnswatcher could neither
# read nor replace. dnswatcher itself always runs as the dnswatcher user.
set -eu
main() {
dir="${DNSWATCHER_DATA_DIR:-/var/lib/dnswatcher}"
mkdir -p "$dir"
chown -R dnswatcher:dnswatcher "$dir"
chmod 700 "$dir"
exec su-exec dnswatcher /usr/local/bin/dnswatcher "$@"
}
main "$@"
@@ -1,4 +1,4 @@
// Package livednstest runs the live DNS operations of tests. Tests that
// Package livedns runs the live DNS operations of tests. Tests that
// look something up in DNS query live DNS servers, never a stand-in —
// see TESTING.md. Nothing here mocks, fakes, stubs, records or replays
// DNS, and nothing here skips a test: it only changes *how* the live
@@ -22,7 +22,7 @@
// first attempt. A fault in the code under test that leaves
// nothing to check looks the same as live DNS not answering, and
// fails only after the last attempt.
package livednstest
package livedns
import (
"context"
@@ -1,4 +1,4 @@
package livednstest_test
package livedns_test
import (
"context"
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/livedns"
)
// Tests for the retry and the concurrency limit themselves. They
@@ -21,11 +21,11 @@ func TestRetryRecoversFromTransientFailure(t *testing.T) {
attempts := 0
livednstest.Retry(t, "transient", func(_ context.Context) error {
livedns.Retry(t, "transient", func(_ context.Context) error {
attempts++
if attempts < wantAttempts {
return livednstest.ErrNoAnswer
return livedns.ErrNoAnswer
}
return nil
@@ -37,19 +37,19 @@ func TestRetryRecoversFromTransientFailure(t *testing.T) {
func TestRetryGivesEachAttemptADeadline(t *testing.T) {
t.Parallel()
livednstest.Retry(t, "deadline", func(ctx context.Context) error {
livedns.Retry(t, "deadline", func(ctx context.Context) error {
deadline, ok := ctx.Deadline()
assert.True(t, ok, "attempt should carry a deadline")
remaining := time.Until(deadline)
assert.LessOrEqual(t, remaining, livednstest.AttemptTimeout)
assert.LessOrEqual(t, remaining, livedns.AttemptTimeout)
// Lower bound too: without one this passes for a
// deadline far shorter than intended, which would
// silently turn every live attempt into an instant
// timeout.
assert.Greater(t, remaining, livednstest.AttemptTimeout/2)
assert.Greater(t, remaining, livedns.AttemptTimeout/2)
return nil
})
@@ -73,7 +73,7 @@ func TestRunBoundsConcurrency(t *testing.T) {
go func() {
defer wg.Done()
_ = livednstest.Run(func(_ context.Context) error {
_ = livedns.Run(func(_ context.Context) error {
mu.Lock()
inFlight++
@@ -97,7 +97,7 @@ func TestRunBoundsConcurrency(t *testing.T) {
assert.Positive(t, maxSeen)
assert.LessOrEqual(
t, maxSeen, livednstest.Concurrency,
t, maxSeen, livedns.Concurrency,
"live queries must stay under the package-wide gate",
)
}
+14 -14
View File
@@ -9,7 +9,7 @@ import (
"strings"
"testing"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/livedns"
"sneak.berlin/go/dnswatcher/internal/resolver"
)
@@ -20,9 +20,9 @@ import (
// Tests that look something up in DNS query live DNS servers, never a
// stand-in; logic that works on record data may be tested on that
// data with no lookup (see TESTING.md). Each live operation below goes
// through livednstest.Retry, which bounds how many resolutions are in
// through livedns.Retry, which bounds how many resolutions are in
// flight at once and retries an operation that got no answer (see
// package livednstest).
// package livedns).
//
// Where an assertion spans several independent nameservers, a quorum
// is enough: a strict majority answering as expected. A server that
@@ -162,7 +162,7 @@ func liveFindAuthoritative(
var out []string
livednstest.Retry(
livedns.Retry(
t,
"FindAuthoritativeNameservers("+domain+")",
func(ctx context.Context) error {
@@ -174,7 +174,7 @@ func liveFindAuthoritative(
if len(ns) == 0 {
return fmt.Errorf(
"%w: %s has no nameservers",
livednstest.ErrNoAnswer, domain,
livedns.ErrNoAnswer, domain,
)
}
@@ -198,7 +198,7 @@ func liveLookupNS(
var out []string
livednstest.Retry(
livedns.Retry(
t,
"LookupNS("+domain+")",
func(ctx context.Context) error {
@@ -210,7 +210,7 @@ func liveLookupNS(
if len(ns) == 0 {
return fmt.Errorf(
"%w: %s has no nameservers",
livednstest.ErrNoAnswer, domain,
livedns.ErrNoAnswer, domain,
)
}
@@ -240,7 +240,7 @@ func liveQueryNameserver(
var out *resolver.NameserverResponse
livednstest.Retry(
livedns.Retry(
t,
what,
func(ctx context.Context) error {
@@ -255,7 +255,7 @@ func liveQueryNameserver(
resp.Status == resolver.StatusError {
return fmt.Errorf(
"%w: %s returned %s: %s",
livednstest.ErrNoAnswer, nameserver,
livedns.ErrNoAnswer, nameserver,
resp.Status, resp.Error,
)
}
@@ -282,7 +282,7 @@ func liveQueryAllNameservers(
var out map[string]*resolver.NameserverResponse
livednstest.Retry(
livedns.Retry(
t,
"QueryAllNameservers("+hostname+")",
func(ctx context.Context) error {
@@ -294,7 +294,7 @@ func liveQueryAllNameservers(
if len(results) == 0 {
return fmt.Errorf(
"%w: no nameservers queried for %s",
livednstest.ErrNoAnswer, hostname,
livedns.ErrNoAnswer, hostname,
)
}
@@ -327,7 +327,7 @@ func liveResolveIPs(
var out []string
livednstest.Retry(
livedns.Retry(
t,
"ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error {
@@ -339,7 +339,7 @@ func liveResolveIPs(
if len(ips) == 0 {
return fmt.Errorf(
"%w: no addresses for %s",
livednstest.ErrNoAnswer, hostname,
livedns.ErrNoAnswer, hostname,
)
}
@@ -366,7 +366,7 @@ func liveResolveIPsAllowingEmpty(
var out []string
livednstest.Retry(
livedns.Retry(
t,
"ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error {
+1 -1
View File
@@ -33,7 +33,7 @@ func newTestResolver(t *testing.T) *resolver.Resolver {
// findOneNSForDomain picks one authoritative nameserver to aim a
// test at. Quorum handling lives in livedns_test.go, and the live-DNS
// retry and concurrency limit in package livednstest.
// retry and concurrency limit in package livedns.
func findOneNSForDomain(
t *testing.T,
r *resolver.Resolver,
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"time"
"sneak.berlin/go/dnswatcher/internal/config"
"sneak.berlin/go/dnswatcher/internal/livednstest"
"sneak.berlin/go/dnswatcher/internal/livedns"
"sneak.berlin/go/dnswatcher/internal/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state"
@@ -195,7 +195,7 @@ func checkOnce(
return fmt.Errorf(
"%s: %w, or the watcher saved no fresh "+
"result for it",
name, livednstest.ErrNoAnswer,
name, livedns.ErrNoAnswer,
)
}
}
@@ -219,7 +219,7 @@ func runChecks(
var deps *testDeps
livednstest.Retry(t, "watcher checks", func(ctx context.Context) error {
livedns.Retry(t, "watcher checks", func(ctx context.Context) error {
var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg)