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:
@@ -148,6 +148,11 @@ func New(
|
||||
|
||||
lifecycle.Append(fx.Hook{
|
||||
OnStart: func(_ context.Context) error {
|
||||
err := state.checkDataDirWritable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return state.Load()
|
||||
},
|
||||
OnStop: func(_ context.Context) error {
|
||||
@@ -345,3 +350,27 @@ func (s *State) GetCertificateState(
|
||||
|
||||
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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
// current multi-hostname format.
|
||||
func TestPortStateUnmarshalJSON_NewFormat(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user