Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b425692bfd |
+6
-26
@@ -2,7 +2,6 @@
|
|||||||
# The linter is invoked directly rather than through `make lint`: that
|
# The linter is invoked directly rather than through `make lint`: that
|
||||||
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
# target shells out to `docker build -f Dockerfile.lint`, and there is
|
||||||
# no docker daemon inside a docker build.
|
# no docker daemon inside a docker build.
|
||||||
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
|
||||||
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-10
|
||||||
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
|
||||||
|
|
||||||
@@ -16,7 +15,6 @@ RUN make fmt-check
|
|||||||
RUN golangci-lint run --config .golangci.yml ./...
|
RUN golangci-lint run --config .golangci.yml ./...
|
||||||
|
|
||||||
# Build stage
|
# Build stage
|
||||||
# script/cibuild and script/docker name this stage in --no-cache-filter.
|
|
||||||
# golang 1.25-alpine, 2026-02-28
|
# golang 1.25-alpine, 2026-02-28
|
||||||
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
FROM golang@sha256:f6751d823c26342f9506c03797d2527668d095b0a15f1862cddb4d927a7a4ced AS builder
|
||||||
|
|
||||||
@@ -43,33 +41,15 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4
|
|||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
|
WORKDIR /app
|
||||||
|
|
||||||
# Run as an unprivileged user that owns the data directory. A fresh named
|
COPY --from=builder /src/bin/dnswatcher /app/dnswatcher
|
||||||
# volume inherits this ownership; a bind-mounted host directory must be
|
|
||||||
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
|
# Create data directory
|
||||||
# fails.
|
RUN mkdir -p /var/lib/dnswatcher
|
||||||
RUN addgroup -S -g 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
|
||||||
|
|
||||||
# Config loading also reads a `.env` file and a file named `dnswatcher`
|
|
||||||
# (any config extension, or none) from the working directory. `/` holds
|
|
||||||
# neither, so every setting comes from the environment. Do not make the
|
|
||||||
# data directory, or the binary's directory, the working directory.
|
|
||||||
WORKDIR /
|
|
||||||
|
|
||||||
USER dnswatcher
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|
||||||
# busybox wget (already in alpine) probes the health endpoint every 10
|
ENTRYPOINT ["/app/dnswatcher"]
|
||||||
# seconds, so the container is healthy well before upaas reads its health
|
|
||||||
# 60 seconds after a deploy and fails the deploy unless it is healthy.
|
|
||||||
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"]
|
|
||||||
|
|||||||
@@ -465,13 +465,9 @@ them. We provide:
|
|||||||
- `script/fmt` — format all code (gofmt -s, goimports)
|
- `script/fmt` — format all code (gofmt -s, goimports)
|
||||||
- `script/fmt-check` — check formatting (read-only)
|
- `script/fmt-check` — check formatting (read-only)
|
||||||
- `script/check` — run test, lint, and fmt-check
|
- `script/check` — run test, lint, and fmt-check
|
||||||
- `script/docker` — build the Docker image tagged via `script/projectname`, with
|
- `script/docker` — build the Docker image tagged via
|
||||||
`--no-cache-filter=lint,builder` so the lint stage and the builder stage,
|
`script/projectname`
|
||||||
which runs the tests, run on every invocation
|
- `script/cibuild` — CI entrypoint: plain `docker build .`
|
||||||
- `script/cibuild` — CI entrypoint: `docker build` with
|
|
||||||
`--no-cache-filter=lint,builder`, so the lint stage and the builder stage,
|
|
||||||
which runs the tests, run on every invocation, because a cached build lints
|
|
||||||
nothing and queries no DNS
|
|
||||||
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
|
- `script/precommit` — run by the git pre-commit hook; `go mod tidy`
|
||||||
guard, then `script/check`
|
guard, then `script/check`
|
||||||
- `script/install-precommit` — install the git pre-commit hook
|
- `script/install-precommit` — install the git pre-commit hook
|
||||||
@@ -512,57 +508,11 @@ docker run -d \
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running under upaas
|
|
||||||
|
|
||||||
[upaas](https://git.eeqj.de/sneak/upaas) builds the image from this
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
- **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
|
|
||||||
([upaas issue 113](https://git.eeqj.de/sneak/upaas/issues/113)). Add a
|
|
||||||
port mapping to container port `8080` only if the dashboard should be
|
|
||||||
public. Otherwise add none: set the app's Docker network in upaas to
|
|
||||||
your reverse proxy's Docker network, and the proxy reaches the app at
|
|
||||||
`upaas-` followed by the app name, port `8080`.
|
|
||||||
- **Required environment:** `DNSWATCHER_TARGETS`, a comma-separated list
|
|
||||||
of the domains and hostnames to watch. dnswatcher refuses to start
|
|
||||||
without it.
|
|
||||||
- **Recommended environment:** at least one notification endpoint
|
|
||||||
(`DNSWATCHER_SLACK_WEBHOOK`, `DNSWATCHER_MATTERMOST_WEBHOOK`,
|
|
||||||
`DNSWATCHER_NTFY_TOPIC`); without one, changes show only on the
|
|
||||||
dashboard. `DNSWATCHER_METRICS_USERNAME` and
|
|
||||||
`DNSWATCHER_METRICS_PASSWORD` serve `/metrics` behind basic auth.
|
|
||||||
- **Leave unset:** `DNSWATCHER_DATA_DIR`, which the image sets to
|
|
||||||
`/var/lib/dnswatcher`, and `PORT`, which defaults to `8080`. Every
|
|
||||||
setting comes from the environment; the image holds no config file.
|
|
||||||
- **Health check:** the image's own, which requests
|
|
||||||
`/.well-known/healthcheck` every 10 seconds. upaas reads the
|
|
||||||
container's health 60 seconds after a deploy and marks the deploy
|
|
||||||
failed unless it is `healthy`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Monitoring Lifecycle
|
## Monitoring Lifecycle
|
||||||
|
|
||||||
1. **Startup**: Check that the data directory can be written, and exit
|
1. **Startup**: Load state from disk. If no state file exists, start
|
||||||
with an error naming it if not. Load state from disk. If no state
|
with empty state (first check will establish baseline without
|
||||||
file exists, start with empty state (first check will establish
|
triggering change notifications).
|
||||||
baseline without triggering change notifications).
|
|
||||||
2. **Initial check**: Immediately perform all DNS, port, and TLS checks
|
2. **Initial check**: Immediately perform all DNS, port, and TLS checks
|
||||||
on startup.
|
on startup.
|
||||||
3. **Periodic checks** (DNS always runs first):
|
3. **Periodic checks** (DNS always runs first):
|
||||||
|
|||||||
@@ -23,17 +23,6 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-09-28: `script/cibuild` and `script/docker` now pass
|
|
||||||
`--no-cache-filter=lint,builder` so lint and tests run every build (closes
|
|
||||||
#115).
|
|
||||||
- 2026-09-28: the server timeout test now drives `Run` and checks the
|
|
||||||
`http.Server` it serves carries the timeouts; corrected the `ReadTimeout`
|
|
||||||
note in that test (closes #120).
|
|
||||||
- 2026-09-28: upaas deploy readiness — runtime image runs as unprivileged
|
|
||||||
`dnswatcher`, Docker `HEALTHCHECK`, startup fails when the data directory is
|
|
||||||
not writable, README "Running under upaas" (closes #147).
|
|
||||||
- 2026-09-21: added behavioural tests for `internal/globals`,
|
|
||||||
`internal/healthcheck`, and `internal/logger` (closes #110).
|
|
||||||
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
|
- 2026-09-21: `go mod tidy` dropped the redundant `golang.org/x/sync`
|
||||||
`// indirect` line so `script/bootstrap` leaves a clean tree (#132)
|
`// indirect` line so `script/bootstrap` leaves a clean tree (#132)
|
||||||
- 2026-08-10: comment-only corrections to `script/bootstrap`,
|
- 2026-08-10: comment-only corrections to `script/bootstrap`,
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
package globals_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestGlobals exercises the package-level version and appname
|
|
||||||
// variables through their setters and read-back via New. These are
|
|
||||||
// shared package state, so the test mutates a global and must run
|
|
||||||
// sequentially; it cannot use t.Parallel().
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // mutates shared package-level globals, must run sequentially
|
|
||||||
func TestGlobals(t *testing.T) {
|
|
||||||
versions := []string{"v1.2.3", "dev", "", "v1.2.3-4-gabcdef"}
|
|
||||||
for _, want := range versions {
|
|
||||||
globals.SetVersion(want)
|
|
||||||
|
|
||||||
g, err := globals.New(nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, want, g.Version,
|
|
||||||
"New must surface the version set by SetVersion")
|
|
||||||
}
|
|
||||||
|
|
||||||
names := []string{"dnswatcher", "other", ""}
|
|
||||||
for _, want := range names {
|
|
||||||
globals.SetAppname(want)
|
|
||||||
|
|
||||||
g, err := globals.New(nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, want, g.Appname,
|
|
||||||
"New must surface the appname set by SetAppname")
|
|
||||||
}
|
|
||||||
|
|
||||||
// New returns a snapshot: a later SetVersion must not mutate a
|
|
||||||
// Globals handed out earlier.
|
|
||||||
globals.SetVersion("first")
|
|
||||||
|
|
||||||
g, err := globals.New(nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
globals.SetVersion("second")
|
|
||||||
assert.Equal(t, "first", g.Version,
|
|
||||||
"a Globals returned by New must not change when the "+
|
|
||||||
"package variable is set again")
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
package healthcheck_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"go.uber.org/fx"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/config"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
// recordingLifecycle is a minimal fx.Lifecycle that records the hooks
|
|
||||||
// appended to it, so healthcheck.New can be exercised through its real
|
|
||||||
// constructor without standing up a whole fx application.
|
|
||||||
type recordingLifecycle struct {
|
|
||||||
hooks []fx.Hook
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l *recordingLifecycle) Append(hook fx.Hook) {
|
|
||||||
l.hooks = append(l.hooks, hook)
|
|
||||||
}
|
|
||||||
|
|
||||||
// newHealthcheck builds a Healthcheck through the real constructor and
|
|
||||||
// runs the registered OnStart hook so StartupTime is set the same way
|
|
||||||
// the fx lifecycle would set it.
|
|
||||||
func newHealthcheck(
|
|
||||||
t *testing.T,
|
|
||||||
maintenance bool,
|
|
||||||
version string,
|
|
||||||
) *healthcheck.Healthcheck {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
g := &globals.Globals{Appname: "dnswatcher", Version: version}
|
|
||||||
|
|
||||||
log, err := logger.New(nil, logger.Params{Globals: g})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
lifecycle := &recordingLifecycle{}
|
|
||||||
|
|
||||||
hc, err := healthcheck.New(lifecycle, healthcheck.Params{
|
|
||||||
Globals: g,
|
|
||||||
Config: &config.Config{MaintenanceMode: maintenance},
|
|
||||||
Logger: log,
|
|
||||||
})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
require.Len(t, lifecycle.hooks, 1,
|
|
||||||
"New must register exactly one lifecycle hook")
|
|
||||||
require.NotNil(t, lifecycle.hooks[0].OnStart)
|
|
||||||
require.NoError(t, lifecycle.hooks[0].OnStart(context.Background()))
|
|
||||||
|
|
||||||
return hc
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckStatusAndPayloadShape(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hc := newHealthcheck(t, false, "v9.9.9")
|
|
||||||
resp := hc.Check()
|
|
||||||
|
|
||||||
assert.Equal(t, "ok", resp.Status)
|
|
||||||
|
|
||||||
// The JSON shape and field names are part of the contract for the
|
|
||||||
// /health and /.well-known/healthcheck routes, so assert on the
|
|
||||||
// exact set of keys the response marshals to.
|
|
||||||
raw, err := json.Marshal(resp)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var fields map[string]json.RawMessage
|
|
||||||
require.NoError(t, json.Unmarshal(raw, &fields))
|
|
||||||
|
|
||||||
wantKeys := []string{
|
|
||||||
"status",
|
|
||||||
"now",
|
|
||||||
"uptimeSeconds",
|
|
||||||
"uptimeHuman",
|
|
||||||
"version",
|
|
||||||
"appname",
|
|
||||||
"maintenanceMode",
|
|
||||||
}
|
|
||||||
assert.Len(t, fields, len(wantKeys),
|
|
||||||
"response must marshal to exactly the documented fields")
|
|
||||||
|
|
||||||
for _, key := range wantKeys {
|
|
||||||
assert.Contains(t, fields, key, "missing JSON field %q", key)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Now field is documented as RFC3339Nano; a change to the
|
|
||||||
// format constant should turn this red.
|
|
||||||
_, err = time.Parse(time.RFC3339Nano, resp.Now)
|
|
||||||
assert.NoError(t, err, "Now must be RFC3339Nano")
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckMaintenanceModeReflectsConfig(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
maintenance bool
|
|
||||||
}{
|
|
||||||
{"maintenance off", false},
|
|
||||||
{"maintenance on", true},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hc := newHealthcheck(t, tt.maintenance, "test")
|
|
||||||
resp := hc.Check()
|
|
||||||
assert.Equal(t, tt.maintenance, resp.Maintenance,
|
|
||||||
"maintenanceMode must mirror Config.MaintenanceMode")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCheckSurfacesVersionAndAppname(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
hc := newHealthcheck(t, false, "surfaced-version-123")
|
|
||||||
resp := hc.Check()
|
|
||||||
|
|
||||||
assert.Equal(t, "surfaced-version-123", resp.Version,
|
|
||||||
"version from globals must appear in the payload")
|
|
||||||
assert.Equal(t, "dnswatcher", resp.Appname)
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package logger_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
||||||
)
|
|
||||||
|
|
||||||
func newTestLogger(t *testing.T) *logger.Logger {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
g := &globals.Globals{Appname: "dnswatcher", Version: "test"}
|
|
||||||
|
|
||||||
l, err := logger.New(nil, logger.Params{Globals: g})
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return l
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewReturnsUsableLogger checks that the constructor yields a
|
|
||||||
// working *slog.Logger.
|
|
||||||
func TestNewReturnsUsableLogger(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
l := newTestLogger(t)
|
|
||||||
require.NotNil(t, l.Get(), "Get must return a non-nil logger")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestDefaultLevelExcludesDebug verifies the default configuration
|
|
||||||
// logs at info: debug records are suppressed, info records pass.
|
|
||||||
func TestDefaultLevelExcludesDebug(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
log := newTestLogger(t).Get()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
assert.False(t, log.Enabled(ctx, slog.LevelDebug),
|
|
||||||
"debug must be suppressed at the default level")
|
|
||||||
assert.True(t, log.Enabled(ctx, slog.LevelInfo),
|
|
||||||
"info must be enabled at the default level")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestEnableDebugLoggingChangesLevel verifies the debug and non-debug
|
|
||||||
// configurations differ as intended: enabling debug makes debug
|
|
||||||
// records pass where they previously did not.
|
|
||||||
func TestEnableDebugLoggingChangesLevel(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
l := newTestLogger(t)
|
|
||||||
log := l.Get()
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
require.False(t, log.Enabled(ctx, slog.LevelDebug),
|
|
||||||
"debug must start disabled")
|
|
||||||
|
|
||||||
l.EnableDebugLogging()
|
|
||||||
|
|
||||||
assert.True(t, log.Enabled(ctx, slog.LevelDebug),
|
|
||||||
"debug must be enabled after EnableDebugLogging")
|
|
||||||
}
|
|
||||||
@@ -5,20 +5,15 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// NewHTTPServer exports newHTTPServer for testing.
|
||||||
|
func NewHTTPServer(
|
||||||
|
listenAddr string,
|
||||||
|
handler http.Handler,
|
||||||
|
) *http.Server {
|
||||||
|
return newHTTPServer(listenAddr, handler)
|
||||||
|
}
|
||||||
|
|
||||||
// RequestTimeout exports the handler execution budget applied by
|
// RequestTimeout exports the handler execution budget applied by
|
||||||
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
|
// chimw.Timeout in SetupRoutes, so tests can assert the relationship
|
||||||
// between it and the server's WriteTimeout.
|
// between it and the server's WriteTimeout.
|
||||||
const RequestTimeout time.Duration = requestTimeout
|
const RequestTimeout time.Duration = requestTimeout
|
||||||
|
|
||||||
// SetListenPort overrides the port Run binds. A test uses it to hand
|
|
||||||
// Run an unbindable port so ListenAndServe fails immediately and Run
|
|
||||||
// returns after storing its http.Server.
|
|
||||||
func SetListenPort(s *Server, port int) {
|
|
||||||
s.port = port
|
|
||||||
}
|
|
||||||
|
|
||||||
// HTTPServerOf returns the http.Server that Run built and stored, so a
|
|
||||||
// test can inspect the timeouts the running server actually carries.
|
|
||||||
func HTTPServerOf(s *Server) *http.Server {
|
|
||||||
return s.httpServer
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,130 +1,112 @@
|
|||||||
package server_test
|
package server_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/spf13/viper"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/config"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/handlers"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/middleware"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/notify"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/server"
|
"sneak.berlin/go/dnswatcher/internal/server"
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// buildServer wires a *server.Server exactly as cmd/dnswatcher does,
|
// noopHandler stands in for the router; newHTTPServer only stores it.
|
||||||
// minus the watcher/resolver subtree that would touch live DNS. fx
|
func noopHandler() http.Handler {
|
||||||
// builds the object graph but the lifecycle is never started, so no
|
return http.HandlerFunc(
|
||||||
// OnStart hook runs and nothing listens or resolves. The caller must
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
// first configure viper (config.New reads it), which is also why the
|
w.WriteHeader(http.StatusOK)
|
||||||
// caller cannot run in parallel.
|
},
|
||||||
func buildServer(t *testing.T) *server.Server {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var srv *server.Server
|
|
||||||
|
|
||||||
app := fx.New(
|
|
||||||
fx.NopLogger,
|
|
||||||
fx.Provide(
|
|
||||||
globals.New,
|
|
||||||
logger.New,
|
|
||||||
config.New,
|
|
||||||
state.New,
|
|
||||||
healthcheck.New,
|
|
||||||
notify.New,
|
|
||||||
middleware.New,
|
|
||||||
handlers.New,
|
|
||||||
server.New,
|
|
||||||
),
|
|
||||||
fx.Populate(&srv),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
err := app.Err()
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("building server graph: %v", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return srv
|
// TestHTTPServerTimeoutsAreSet asserts that every socket-level
|
||||||
}
|
// timeout is configured. A zero value in net/http means "no limit",
|
||||||
|
// so a refactor that silently drops one of these reintroduces the
|
||||||
// TestRunWiresSocketTimeouts pins that the http.Server the running
|
// slowloris / unreaped-keep-alive exposure this guards against.
|
||||||
// server actually serves — the one Run builds and hands to
|
|
||||||
// ListenAndServe — carries every socket-level timeout, plus the two
|
|
||||||
// relationships the values must satisfy.
|
|
||||||
//
|
//
|
||||||
// Run is driven to completion with an unbindable port: it builds and
|
// The assertions are on the configured field values only; nothing
|
||||||
// stores s.httpServer, then ListenAndServe fails at once and Run
|
// here measures elapsed time, so the test cannot flake on timing.
|
||||||
// returns without ever listening. The assertions run in the same
|
func TestHTTPServerTimeoutsAreSet(t *testing.T) {
|
||||||
// goroutine after Run returns, so reading s.httpServer is free of any
|
t.Parallel()
|
||||||
// data race. Nothing here measures elapsed time.
|
|
||||||
//
|
|
||||||
// ReadTimeout must be at least ReadHeaderTimeout. net/http reads the
|
|
||||||
// headers under ReadHeaderTimeout, then sets the read deadline for the
|
|
||||||
// rest of the request to ReadTimeout, counted from when it started
|
|
||||||
// reading the request. If ReadTimeout were smaller, a request whose
|
|
||||||
// headers arrived after ReadTimeout but within ReadHeaderTimeout would
|
|
||||||
// get a read deadline that had already passed, so reading its body
|
|
||||||
// would fail at once.
|
|
||||||
func TestRunWiresSocketTimeouts(t *testing.T) {
|
|
||||||
// Sets an env var and touches viper global state, so like the
|
|
||||||
// config tests it cannot use t.Parallel.
|
|
||||||
viper.Reset()
|
|
||||||
t.Setenv("DNSWATCHER_TARGETS", "example.com")
|
|
||||||
|
|
||||||
srv := buildServer(t)
|
srv := server.NewHTTPServer(":8080", noopHandler())
|
||||||
server.SetListenPort(srv, -1)
|
|
||||||
|
|
||||||
srv.Run()
|
if srv.ReadTimeout <= 0 {
|
||||||
|
t.Errorf(
|
||||||
hs := server.HTTPServerOf(srv)
|
"ReadTimeout must be non-zero, got %v",
|
||||||
if hs == nil {
|
srv.ReadTimeout,
|
||||||
t.Fatal("Run did not build an http.Server")
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.ReadTimeout <= 0 {
|
if srv.ReadHeaderTimeout <= 0 {
|
||||||
t.Errorf("ReadTimeout must be non-zero, got %v", hs.ReadTimeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if hs.ReadHeaderTimeout <= 0 {
|
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"ReadHeaderTimeout must be non-zero, got %v",
|
"ReadHeaderTimeout must be non-zero, got %v",
|
||||||
hs.ReadHeaderTimeout,
|
srv.ReadHeaderTimeout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.WriteTimeout <= 0 {
|
if srv.WriteTimeout <= 0 {
|
||||||
t.Errorf("WriteTimeout must be non-zero, got %v", hs.WriteTimeout)
|
t.Errorf(
|
||||||
|
"WriteTimeout must be non-zero, got %v",
|
||||||
|
srv.WriteTimeout,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.IdleTimeout <= 0 {
|
if srv.IdleTimeout <= 0 {
|
||||||
t.Errorf("IdleTimeout must be non-zero, got %v", hs.IdleTimeout)
|
t.Errorf(
|
||||||
|
"IdleTimeout must be non-zero, got %v",
|
||||||
|
srv.IdleTimeout,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if hs.WriteTimeout <= server.RequestTimeout {
|
// TestWriteTimeoutExceedsHandlerBudget pins the one relationship the
|
||||||
|
// values must satisfy. net/http arms the write deadline once request
|
||||||
|
// headers are read, so it covers handler execution plus the response
|
||||||
|
// flush. If WriteTimeout were not greater than the chimw.Timeout
|
||||||
|
// handler budget, the connection would be severed before a handler
|
||||||
|
// that used its full budget could respond, making that budget
|
||||||
|
// unreachable.
|
||||||
|
func TestWriteTimeoutExceedsHandlerBudget(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := server.NewHTTPServer(":8080", noopHandler())
|
||||||
|
|
||||||
|
if srv.WriteTimeout <= server.RequestTimeout {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"WriteTimeout (%v) must exceed handler budget (%v)",
|
"WriteTimeout (%v) must exceed handler budget (%v)",
|
||||||
hs.WriteTimeout,
|
srv.WriteTimeout,
|
||||||
server.RequestTimeout,
|
server.RequestTimeout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if hs.ReadTimeout < hs.ReadHeaderTimeout {
|
// TestReadTimeoutCoversHeaderTimeout asserts the read deadline for
|
||||||
|
// the whole request is at least as long as the header-only deadline;
|
||||||
|
// a smaller ReadTimeout would make ReadHeaderTimeout unreachable.
|
||||||
|
func TestReadTimeoutCoversHeaderTimeout(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
srv := server.NewHTTPServer(":8080", noopHandler())
|
||||||
|
|
||||||
|
if srv.ReadTimeout < srv.ReadHeaderTimeout {
|
||||||
t.Errorf(
|
t.Errorf(
|
||||||
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
|
"ReadTimeout (%v) must be >= ReadHeaderTimeout (%v)",
|
||||||
hs.ReadTimeout,
|
srv.ReadTimeout,
|
||||||
hs.ReadHeaderTimeout,
|
srv.ReadHeaderTimeout,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if hs.Handler != srv {
|
// TestHTTPServerAddrAndHandler covers the rest of the constructor so
|
||||||
t.Errorf(
|
// a future edit cannot drop the listen address or the handler.
|
||||||
"Run wired handler %T, want the *server.Server",
|
func TestHTTPServerAddrAndHandler(t *testing.T) {
|
||||||
hs.Handler,
|
t.Parallel()
|
||||||
)
|
|
||||||
|
srv := server.NewHTTPServer(":9999", noopHandler())
|
||||||
|
|
||||||
|
if srv.Addr != ":9999" {
|
||||||
|
t.Errorf("Addr = %q, want %q", srv.Addr, ":9999")
|
||||||
|
}
|
||||||
|
|
||||||
|
if srv.Handler == nil {
|
||||||
|
t.Error("Handler must not be nil")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,11 +148,6 @@ func New(
|
|||||||
|
|
||||||
lifecycle.Append(fx.Hook{
|
lifecycle.Append(fx.Hook{
|
||||||
OnStart: func(_ context.Context) error {
|
OnStart: func(_ context.Context) error {
|
||||||
err := state.checkDataDirWritable()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return state.Load()
|
return state.Load()
|
||||||
},
|
},
|
||||||
OnStop: func(_ context.Context) error {
|
OnStop: func(_ context.Context) error {
|
||||||
@@ -350,27 +345,3 @@ func (s *State) GetCertificateState(
|
|||||||
|
|
||||||
return cs, ok
|
return cs, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkDataDirWritable creates the data directory if needed, then writes
|
|
||||||
// and removes the temp file that Save uses. It runs at startup so that an
|
|
||||||
// unwritable directory stops the process, instead of the process running
|
|
||||||
// with every save failing and only logged.
|
|
||||||
func (s *State) checkDataDirWritable() error {
|
|
||||||
dir := s.config.DataDir
|
|
||||||
tmpPath := s.config.StatePath() + ".tmp"
|
|
||||||
|
|
||||||
err := os.MkdirAll(dir, dirPermissions)
|
|
||||||
if err == nil {
|
|
||||||
err = os.WriteFile(tmpPath, nil, filePermissions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err == nil {
|
|
||||||
err = os.Remove(tmpPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("data directory %s is not writable: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,16 +4,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/config"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/globals"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/logger"
|
|
||||||
"sneak.berlin/go/dnswatcher/internal/state"
|
"sneak.berlin/go/dnswatcher/internal/state"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -499,107 +493,6 @@ func TestSaveWritePermissionError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// startState builds a State through the real constructor and runs its
|
|
||||||
// startup hook against dataDir, returning the startup error.
|
|
||||||
func startState(t *testing.T, dataDir string) error {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
g, err := globals.New(nil)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("globals.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log, err := logger.New(nil, logger.Params{Globals: g})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("logger.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
lifecycle := fxtest.NewLifecycle(t)
|
|
||||||
|
|
||||||
_, err = state.New(lifecycle, state.Params{
|
|
||||||
Logger: log,
|
|
||||||
Config: &config.Config{DataDir: dataDir},
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("state.New: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return lifecycle.Start(t.Context())
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStartupFailsWhenDataDirNotWritable verifies that startup stops
|
|
||||||
// with an error naming the data directory when it cannot be written.
|
|
||||||
// The directory's parent is a regular file, which also fails as root.
|
|
||||||
func TestStartupFailsWhenDataDirNotWritable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parent := filepath.Join(t.TempDir(), "file")
|
|
||||||
|
|
||||||
err := os.WriteFile(parent, nil, 0o600)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("writing file: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
dataDir := filepath.Join(parent, "data")
|
|
||||||
|
|
||||||
err = startState(t, dataDir)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("startup should fail when the data directory is not writable")
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "data directory " + dataDir + " is not writable"
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("startup error %q does not contain %q", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStartupFailsWhenExistingDataDirNotWritable verifies that startup
|
|
||||||
// stops when the data directory exists but the temp file that saving uses
|
|
||||||
// cannot be written in it. A directory sitting at the temp file's path
|
|
||||||
// makes that write fail, which also holds as root.
|
|
||||||
func TestStartupFailsWhenExistingDataDirNotWritable(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dataDir := t.TempDir()
|
|
||||||
|
|
||||||
err := os.Mkdir(filepath.Join(dataDir, "state.json.tmp"), 0o700)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("creating directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
err = startState(t, dataDir)
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("startup should fail when the data directory is not writable")
|
|
||||||
}
|
|
||||||
|
|
||||||
want := "data directory " + dataDir + " is not writable"
|
|
||||||
if !strings.Contains(err.Error(), want) {
|
|
||||||
t.Errorf("startup error %q does not contain %q", err, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStartupCreatesDataDir verifies that startup creates a missing
|
|
||||||
// data directory and leaves nothing behind in it.
|
|
||||||
func TestStartupCreatesDataDir(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
dataDir := filepath.Join(t.TempDir(), "data")
|
|
||||||
|
|
||||||
err := startState(t, dataDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("startup error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
entries, err := os.ReadDir(dataDir)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("reading data directory: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(entries) != 0 {
|
|
||||||
t.Errorf("startup left %d entries in the data directory", len(entries))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the
|
// TestPortStateUnmarshalJSON_NewFormat verifies deserialization of the
|
||||||
// current multi-hostname format.
|
// current multi-hostname format.
|
||||||
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
|
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
|
||||||
|
|||||||
+2
-6
@@ -1,18 +1,14 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
# script/cibuild: run the CI build. The Dockerfile's lint stage runs
|
||||||
# make fmt-check and golangci-lint; its builder stage runs make test
|
# make fmt-check and golangci-lint; its builder stage runs make test
|
||||||
# and make build.
|
# and make build. A successful build implies all of those passed.
|
||||||
#
|
|
||||||
# --no-cache-filter=lint,builder runs both stages on every invocation;
|
|
||||||
# otherwise an unchanged tree is served from the layer cache and passes
|
|
||||||
# without linting or querying live DNS.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --no-cache-filter=lint,builder .
|
docker build .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+2
-6
@@ -1,10 +1,6 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
# script/docker: build the Docker image tagged with the project name.
|
# script/docker: build the Docker image tagged with the project name.
|
||||||
# The tag comes from script/projectname.
|
# Identical in all repos; the tag comes from script/projectname.
|
||||||
#
|
|
||||||
# --no-cache-filter=lint,builder runs the lint stage and the builder
|
|
||||||
# stage (make test) on every invocation; otherwise an unchanged tree is
|
|
||||||
# served from the layer cache without linting or querying live DNS.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
@@ -12,7 +8,7 @@ ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
|||||||
|
|
||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
docker build --no-cache-filter=lint,builder -t "$("$SCRIPT_DIR/projectname")" .
|
docker build -t "$("$SCRIPT_DIR/projectname")" .
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user