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: >- desc: >-
Test-support code belongs in test files and in packages whose Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary. 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 # Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly. # listed here. Every entry matches the module path exactly.
gomodguard_v2: gomodguard_v2:
+8 -10
View File
@@ -41,18 +41,15 @@ RUN make build
# alpine 3.21, 2026-02-28 # alpine 3.21, 2026-02-28
FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 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 --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 # dnswatcher runs as this unprivileged user. The entrypoint creates the
# volume inherits this ownership; a bind-mounted host directory must be # data directory and gives it to this user on every start.
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
# fails.
RUN addgroup -S -g 10001 dnswatcher \ RUN addgroup -S -g 10001 dnswatcher \
&& adduser -S -G dnswatcher -u 10001 dnswatcher \ && adduser -S -G dnswatcher -u 10001 dnswatcher
&& mkdir -p /var/lib/dnswatcher \
&& chown dnswatcher:dnswatcher /var/lib/dnswatcher
ENV DNSWATCHER_DATA_DIR=/var/lib/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. # data directory, or the binary's directory, the working directory.
WORKDIR / 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 EXPOSE 8080
@@ -72,4 +70,4 @@ EXPOSE 8080
HEALTHCHECK --interval=10s --timeout=5s --start-period=10s --retries=3 \ 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 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 tlscheck/tlscheck.go TLS certificate inspector
notify/notify.go Notification service (Slack, Mattermost, ntfy) notify/notify.go Notification service (Slack, Mattermost, ntfy)
watcher/watcher.go Main monitoring orchestrator and scheduler 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) 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 - **Branch:** `prod`. `prod` is cut from `main`, and merging a `main` to
`prod` pull request is a deploy. `prod` pull request is a deploy.
- **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where - **Volume:** one host directory mounted at `/var/lib/dnswatcher`, where
the state file lives. upaas bind-mounts the host path it is given and the state file lives.
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
```
- **Network and port:** the dashboard is unauthenticated and shows every - **Network and port:** the dashboard is unauthenticated and shows every
watched name and recent alert, and upaas publishes every mapped port on watched name and recent alert, and upaas publishes every mapped port on
all interfaces of the host 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) - Query timeout is calibrated to 3× maximum antipodal RTT (~300ms)
plus processing margin plus processing margin
- Root server fan-out is limited to reduce parallel query load - 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 which limits how many run at once in a test binary and retries a
lookup that got none lookup that got none
- Flaky failures from transient network issues are acceptable and - 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 # Completed Steps
- 2026-09-29: the live-DNS test package is renamed `internal/livednstest` and - 2026-09-29: the image sets up its own data directory. Its entrypoint,
added to the `test-support` `deny` list in `.golangci.yml`, so `make lint` `deploy/docker-entrypoint.sh`, starts as root, creates the data directory if
fails when program code imports it (closes #164). 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 - 2026-09-29: `.golangci.yml` re-fetched unchanged from `sneak/prompts`. It
replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no replaces the deprecated `gomodguard` with `gomodguard_v2`, so `make lint` no
longer warns about it, and turns on `depguard` with the org `test-support` 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 record and nameserver changes by preparing the saved state a check starts
from; the resolver timeout test queries an address that never answers, and from; the resolver timeout test queries an address that never answers, and
`NewFromLoggerWithClient`, used only by its stand-in client, is gone. The `NewFromLoggerWithClient`, used only by its stand-in client, is gone. The
live-DNS retry and concurrency limit moved to `internal/livednstest`, which live-DNS retry and concurrency limit moved to `internal/livedns`, which both
both test packages use. `TESTING.md` states the README's rule (closes #159). 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 - 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 nameservers start to disagree or where a nameserver that disagrees first
appears, instead of on every check while they disagree, and not again after 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 — // look something up in DNS query live DNS servers, never a stand-in —
// see TESTING.md. Nothing here mocks, fakes, stubs, records or replays // see TESTING.md. Nothing here mocks, fakes, stubs, records or replays
// DNS, and nothing here skips a test: it only changes *how* the live // 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 // first attempt. A fault in the code under test that leaves
// nothing to check looks the same as live DNS not answering, and // nothing to check looks the same as live DNS not answering, and
// fails only after the last attempt. // fails only after the last attempt.
package livednstest package livedns
import ( import (
"context" "context"
@@ -1,4 +1,4 @@
package livednstest_test package livedns_test
import ( import (
"context" "context"
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert" "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 // Tests for the retry and the concurrency limit themselves. They
@@ -21,11 +21,11 @@ func TestRetryRecoversFromTransientFailure(t *testing.T) {
attempts := 0 attempts := 0
livednstest.Retry(t, "transient", func(_ context.Context) error { livedns.Retry(t, "transient", func(_ context.Context) error {
attempts++ attempts++
if attempts < wantAttempts { if attempts < wantAttempts {
return livednstest.ErrNoAnswer return livedns.ErrNoAnswer
} }
return nil return nil
@@ -37,19 +37,19 @@ func TestRetryRecoversFromTransientFailure(t *testing.T) {
func TestRetryGivesEachAttemptADeadline(t *testing.T) { func TestRetryGivesEachAttemptADeadline(t *testing.T) {
t.Parallel() t.Parallel()
livednstest.Retry(t, "deadline", func(ctx context.Context) error { livedns.Retry(t, "deadline", func(ctx context.Context) error {
deadline, ok := ctx.Deadline() deadline, ok := ctx.Deadline()
assert.True(t, ok, "attempt should carry a deadline") assert.True(t, ok, "attempt should carry a deadline")
remaining := time.Until(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 // Lower bound too: without one this passes for a
// deadline far shorter than intended, which would // deadline far shorter than intended, which would
// silently turn every live attempt into an instant // silently turn every live attempt into an instant
// timeout. // timeout.
assert.Greater(t, remaining, livednstest.AttemptTimeout/2) assert.Greater(t, remaining, livedns.AttemptTimeout/2)
return nil return nil
}) })
@@ -73,7 +73,7 @@ func TestRunBoundsConcurrency(t *testing.T) {
go func() { go func() {
defer wg.Done() defer wg.Done()
_ = livednstest.Run(func(_ context.Context) error { _ = livedns.Run(func(_ context.Context) error {
mu.Lock() mu.Lock()
inFlight++ inFlight++
@@ -97,7 +97,7 @@ func TestRunBoundsConcurrency(t *testing.T) {
assert.Positive(t, maxSeen) assert.Positive(t, maxSeen)
assert.LessOrEqual( assert.LessOrEqual(
t, maxSeen, livednstest.Concurrency, t, maxSeen, livedns.Concurrency,
"live queries must stay under the package-wide gate", "live queries must stay under the package-wide gate",
) )
} }
+14 -14
View File
@@ -9,7 +9,7 @@ import (
"strings" "strings"
"testing" "testing"
"sneak.berlin/go/dnswatcher/internal/livednstest" "sneak.berlin/go/dnswatcher/internal/livedns"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
) )
@@ -20,9 +20,9 @@ import (
// Tests that look something up in DNS query live DNS servers, never a // 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 // 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 // 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 // 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 // Where an assertion spans several independent nameservers, a quorum
// is enough: a strict majority answering as expected. A server that // is enough: a strict majority answering as expected. A server that
@@ -162,7 +162,7 @@ func liveFindAuthoritative(
var out []string var out []string
livednstest.Retry( livedns.Retry(
t, t,
"FindAuthoritativeNameservers("+domain+")", "FindAuthoritativeNameservers("+domain+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -174,7 +174,7 @@ func liveFindAuthoritative(
if len(ns) == 0 { if len(ns) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: %s has no nameservers", "%w: %s has no nameservers",
livednstest.ErrNoAnswer, domain, livedns.ErrNoAnswer, domain,
) )
} }
@@ -198,7 +198,7 @@ func liveLookupNS(
var out []string var out []string
livednstest.Retry( livedns.Retry(
t, t,
"LookupNS("+domain+")", "LookupNS("+domain+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -210,7 +210,7 @@ func liveLookupNS(
if len(ns) == 0 { if len(ns) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: %s has no nameservers", "%w: %s has no nameservers",
livednstest.ErrNoAnswer, domain, livedns.ErrNoAnswer, domain,
) )
} }
@@ -240,7 +240,7 @@ func liveQueryNameserver(
var out *resolver.NameserverResponse var out *resolver.NameserverResponse
livednstest.Retry( livedns.Retry(
t, t,
what, what,
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -255,7 +255,7 @@ func liveQueryNameserver(
resp.Status == resolver.StatusError { resp.Status == resolver.StatusError {
return fmt.Errorf( return fmt.Errorf(
"%w: %s returned %s: %s", "%w: %s returned %s: %s",
livednstest.ErrNoAnswer, nameserver, livedns.ErrNoAnswer, nameserver,
resp.Status, resp.Error, resp.Status, resp.Error,
) )
} }
@@ -282,7 +282,7 @@ func liveQueryAllNameservers(
var out map[string]*resolver.NameserverResponse var out map[string]*resolver.NameserverResponse
livednstest.Retry( livedns.Retry(
t, t,
"QueryAllNameservers("+hostname+")", "QueryAllNameservers("+hostname+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -294,7 +294,7 @@ func liveQueryAllNameservers(
if len(results) == 0 { if len(results) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: no nameservers queried for %s", "%w: no nameservers queried for %s",
livednstest.ErrNoAnswer, hostname, livedns.ErrNoAnswer, hostname,
) )
} }
@@ -327,7 +327,7 @@ func liveResolveIPs(
var out []string var out []string
livednstest.Retry( livedns.Retry(
t, t,
"ResolveIPAddresses("+hostname+")", "ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error { func(ctx context.Context) error {
@@ -339,7 +339,7 @@ func liveResolveIPs(
if len(ips) == 0 { if len(ips) == 0 {
return fmt.Errorf( return fmt.Errorf(
"%w: no addresses for %s", "%w: no addresses for %s",
livednstest.ErrNoAnswer, hostname, livedns.ErrNoAnswer, hostname,
) )
} }
@@ -366,7 +366,7 @@ func liveResolveIPsAllowingEmpty(
var out []string var out []string
livednstest.Retry( livedns.Retry(
t, t,
"ResolveIPAddresses("+hostname+")", "ResolveIPAddresses("+hostname+")",
func(ctx context.Context) error { 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 // findOneNSForDomain picks one authoritative nameserver to aim a
// test at. Quorum handling lives in livedns_test.go, and the live-DNS // 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( func findOneNSForDomain(
t *testing.T, t *testing.T,
r *resolver.Resolver, r *resolver.Resolver,
+3 -3
View File
@@ -10,7 +10,7 @@ import (
"time" "time"
"sneak.berlin/go/dnswatcher/internal/config" "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/portcheck"
"sneak.berlin/go/dnswatcher/internal/resolver" "sneak.berlin/go/dnswatcher/internal/resolver"
"sneak.berlin/go/dnswatcher/internal/state" "sneak.berlin/go/dnswatcher/internal/state"
@@ -195,7 +195,7 @@ func checkOnce(
return fmt.Errorf( return fmt.Errorf(
"%s: %w, or the watcher saved no fresh "+ "%s: %w, or the watcher saved no fresh "+
"result for it", "result for it",
name, livednstest.ErrNoAnswer, name, livedns.ErrNoAnswer,
) )
} }
} }
@@ -219,7 +219,7 @@ func runChecks(
var deps *testDeps 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 var w *watcher.Watcher
w, deps = newTestWatcher(t, cfg) w, deps = newTestWatcher(t, cfg)