docker: run as non-root, add health check, document upaas (closes #147)
check / check (push) Successful in 5s
check / check (push) Successful in 5s
The runtime image runs as uid 10001, which owns /var/lib/dnswatcher. The working directory is /, so config loading finds no .env or dnswatcher config file there; the binary lives in /usr/local/bin. A Docker HEALTHCHECK probes /.well-known/healthcheck every 10 seconds with busybox wget, well inside the 60 seconds upaas waits. Startup now fails with an error naming the data directory when it cannot be written, instead of running with every save failing. The check creates the directory if needed and writes and removes the temp file Save uses; tests cover the create and the write failing. README gains "Running under upaas": the prod branch, host directory setup, network and port, environment and health check. Model: opus-5-5
This commit was merged in pull request #156.
This commit is contained in:
+24
-6
@@ -41,15 +41,33 @@ FROM alpine@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4
|
|||||||
|
|
||||||
RUN apk add --no-cache ca-certificates tzdata
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
|
|
||||||
WORKDIR /app
|
COPY --from=builder /src/bin/dnswatcher /usr/local/bin/dnswatcher
|
||||||
|
|
||||||
COPY --from=builder /src/bin/dnswatcher /app/dnswatcher
|
# Run as an unprivileged user that owns the data directory. A fresh named
|
||||||
|
# volume inherits this ownership; a bind-mounted host directory must be
|
||||||
# Create data directory
|
# owned by uid 10001 (see "Running under upaas" in README.md), or startup
|
||||||
RUN mkdir -p /var/lib/dnswatcher
|
# fails.
|
||||||
|
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
|
||||||
|
|
||||||
ENTRYPOINT ["/app/dnswatcher"]
|
# busybox wget (already in alpine) probes the health endpoint every 10
|
||||||
|
# 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"]
|
||||||
|
|||||||
@@ -508,11 +508,57 @@ 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**: Load state from disk. If no state file exists, start
|
1. **Startup**: Check that the data directory can be written, and exit
|
||||||
with empty state (first check will establish baseline without
|
with an error naming it if not. Load state from disk. If no state
|
||||||
triggering change notifications).
|
file exists, start with empty state (first check will establish
|
||||||
|
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,6 +23,9 @@ Rationale, Design, TODO, License, Author) if any are still missing.
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 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`,
|
- 2026-09-21: added behavioural tests for `internal/globals`,
|
||||||
`internal/healthcheck`, and `internal/logger` (closes #110).
|
`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`
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ 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 {
|
||||||
@@ -345,3 +350,27 @@ 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,10 +4,16 @@ 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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -493,6 +499,107 @@ 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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user