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:
+80
-16
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user