feat(docker-watcher): phase 12 - hardening

Blue-green zero-downtime deploys, promote flow validation.
Dual auth: local (bcrypt + JWT) and OAuth2/OIDC (any provider).
Auth middleware, login page, auth settings UI.
Structured logging (slog JSON), config export to YAML.
Graceful shutdown with deploy draining.
Multi-stage Dockerfile and production docker-compose.yml.
Swap phase order: Volumes & Env before UI Polish.
This commit is contained in:
2026-03-27 23:20:56 +03:00
parent 5558396bb7
commit 32de5b26a8
30 changed files with 2134 additions and 143 deletions
+80 -16
View File
@@ -2,8 +2,9 @@ package main
import (
"context"
"errors"
"io/fs"
"log"
"log/slog"
"net/http"
"os"
"os/signal"
@@ -13,12 +14,14 @@ import (
dockerwatcher "github.com/alexei/docker-watcher"
"github.com/alexei/docker-watcher/internal/api"
"github.com/alexei/docker-watcher/internal/auth"
"github.com/alexei/docker-watcher/internal/config"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/deployer"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/health"
"github.com/alexei/docker-watcher/internal/logging"
"github.com/alexei/docker-watcher/internal/notify"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/registry"
@@ -27,44 +30,58 @@ import (
)
func main() {
// Initialize structured JSON logging.
logging.Setup()
dataDir := envOrDefault("DATA_DIR", "./data")
if err := os.MkdirAll(dataDir, 0o755); err != nil {
log.Fatalf("create data directory: %v", err)
slog.Error("create data directory", "error", err)
os.Exit(1)
}
// Open database.
dbPath := filepath.Join(dataDir, "docker-watcher.db")
db, err := store.New(dbPath)
if err != nil {
log.Fatalf("open store: %v", err)
slog.Error("open store", "error", err)
os.Exit(1)
}
defer db.Close()
// Import seed config on first launch (idempotent).
seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml")
if err := config.ImportSeed(db, seedPath); err != nil {
log.Fatalf("seed import: %v", err)
slog.Error("seed import", "error", err)
os.Exit(1)
}
// Derive encryption key from environment.
encKey, err := crypto.KeyFromEnv()
if err != nil {
log.Printf("WARNING: %v — encrypted fields will not work", err)
slog.Warn("encryption key not set, using default", "warning", err.Error())
encKey = crypto.DeriveKey("docker-watcher-default-key")
}
// Ensure default admin user exists on first launch.
if err := ensureDefaultAdmin(db); err != nil {
slog.Error("ensure default admin", "error", err)
os.Exit(1)
}
// Initialize Docker client.
dockerClient, err := docker.New()
if err != nil {
log.Fatalf("create docker client: %v", err)
slog.Error("create docker client", "error", err)
os.Exit(1)
}
defer dockerClient.Close()
// Read settings for NPM URL and polling interval.
settings, err := db.GetSettings()
if err != nil {
log.Fatalf("get settings: %v", err)
slog.Error("get settings", "error", err)
os.Exit(1)
}
// Initialize NPM client.
@@ -84,16 +101,17 @@ func main() {
// Ensure webhook secret exists.
_, err = webhook.EnsureWebhookSecret(db)
if err != nil {
log.Fatalf("ensure webhook secret: %v", err)
slog.Error("ensure webhook secret", "error", err)
os.Exit(1)
}
log.Printf("Webhook secret configured (use /api/settings/webhook-url to retrieve)")
slog.Info("webhook secret configured (use /api/settings/webhook-url to retrieve)")
// Initialize registry poller.
poller := registry.NewPoller(db, dep, encKey)
pollingInterval := envOrDefault("POLLING_INTERVAL", settings.PollingInterval)
if pollingInterval != "" {
if err := poller.Start(pollingInterval); err != nil {
log.Printf("WARNING: failed to start poller: %v", err)
slog.Warn("failed to start poller", "error", err)
}
}
@@ -105,7 +123,7 @@ func main() {
// The embed.FS has "web/build" as a prefix, so we sub it to get the root.
webBuildFS, err := fs.Sub(dockerwatcher.WebBuildFS, "web/build")
if err != nil {
log.Printf("WARNING: embedded frontend not available: %v", err)
slog.Warn("embedded frontend not available", "error", err)
} else {
staticHandler := api.StaticHandler(webBuildFS)
// Handle all non-API routes with the static file server.
@@ -129,25 +147,36 @@ func main() {
signal.Notify(done, os.Interrupt, syscall.SIGTERM)
go func() {
log.Printf("Docker Watcher started. Listening on %s", addr)
slog.Info("Docker Watcher started", "addr", addr)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("HTTP server error: %v", err)
slog.Error("HTTP server error", "error", err)
os.Exit(1)
}
}()
<-done
log.Println("Shutting down...")
slog.Info("shutting down...")
// Stop accepting new work.
poller.Stop()
// Drain in-progress deploys.
dep.Drain()
// Shut down HTTP server.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
log.Printf("HTTP server shutdown error: %v", err)
slog.Error("HTTP server shutdown error", "error", err)
}
log.Println("Docker Watcher stopped.")
// Close database.
if err := db.Close(); err != nil {
slog.Error("database close error", "error", err)
}
slog.Info("Docker Watcher stopped")
}
// envOrDefault reads an environment variable or returns the fallback value.
@@ -157,3 +186,38 @@ func envOrDefault(key, fallback string) string {
}
return fallback
}
// ensureDefaultAdmin creates a default admin user on first launch if no users exist.
// The password comes from ADMIN_PASSWORD env var, defaulting to "admin".
func ensureDefaultAdmin(db *store.Store) error {
count, err := db.UserCount()
if err != nil {
return err
}
if count > 0 {
return nil // Users already exist, skip.
}
password := envOrDefault("ADMIN_PASSWORD", "admin")
hash, err := auth.HashPassword(password)
if err != nil {
return err
}
_, err = db.CreateUser(store.User{
Username: "admin",
PasswordHash: hash,
Email: "",
Role: "admin",
})
if err != nil {
// Ignore duplicate key errors (race condition on concurrent startup).
if errors.Is(err, store.ErrNotFound) {
return nil
}
return err
}
slog.Info("default admin user created", "username", "admin")
return nil
}