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
+319
View File
@@ -0,0 +1,319 @@
package api
import (
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/auth"
"github.com/alexei/docker-watcher/internal/store"
)
// login handles POST /api/auth/login.
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var req auth.LoginRequest
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "username and password are required")
return
}
user, err := s.store.GetUserByUsername(req.Username)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
return
}
if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
token, err := s.localAuth.GenerateToken(auth.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
return
}
respondJSON(w, http.StatusOK, token)
}
// currentUser handles GET /api/auth/me — returns the authenticated user.
func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) {
claims, ok := auth.ClaimsFromContext(r.Context())
if !ok {
respondError(w, http.StatusUnauthorized, "not authenticated")
return
}
user, err := s.store.GetUserByID(claims.UserID)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
return
}
respondJSON(w, http.StatusOK, user)
}
// oidcLogin handles GET /api/auth/oidc/login — redirects to OIDC provider.
func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil {
respondError(w, http.StatusBadRequest, "OIDC is not configured")
return
}
// Generate random state.
stateBytes := make([]byte, 16)
if _, err := rand.Read(stateBytes); err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate state")
return
}
state := hex.EncodeToString(stateBytes)
// Store state in a short-lived cookie for validation on callback.
http.SetCookie(w, &http.Cookie{
Name: "oidc_state",
Value: state,
Path: "/api/auth/oidc",
MaxAge: 300, // 5 minutes
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, s.oidcProvider.AuthCodeURL(state), http.StatusFound)
}
// oidcCallback handles GET /api/auth/oidc/callback — exchanges code for tokens.
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
if s.oidcProvider == nil {
respondError(w, http.StatusBadRequest, "OIDC is not configured")
return
}
// Validate state parameter.
stateCookie, err := r.Cookie("oidc_state")
if err != nil || stateCookie.Value == "" {
respondError(w, http.StatusBadRequest, "missing OIDC state")
return
}
if r.URL.Query().Get("state") != stateCookie.Value {
respondError(w, http.StatusBadRequest, "invalid OIDC state")
return
}
// Clear the state cookie.
http.SetCookie(w, &http.Cookie{
Name: "oidc_state",
Value: "",
Path: "/api/auth/oidc",
MaxAge: -1,
})
code := r.URL.Query().Get("code")
if code == "" {
respondError(w, http.StatusBadRequest, "missing authorization code")
return
}
userInfo, err := s.oidcProvider.Exchange(r.Context(), code)
if err != nil {
slog.Error("OIDC exchange failed", "error", err)
respondError(w, http.StatusInternalServerError, "OIDC authentication failed")
return
}
// Find or create local user linked to the OIDC identity.
username := userInfo.Username
if username == "" {
username = userInfo.Email
}
if username == "" {
username = userInfo.Subject
}
user, err := s.store.GetUserByUsername(username)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// Auto-create user from OIDC.
user, err = s.store.CreateUser(store.User{
Username: username,
Email: userInfo.Email,
Role: "viewer", // OIDC users default to viewer; admin promotes via settings
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
} else {
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
return
}
}
token, err := s.localAuth.GenerateToken(auth.Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
return
}
// Redirect to frontend with token in query parameter.
// The frontend extracts the token and stores it in localStorage.
http.Redirect(w, r, "/?token="+token.Token, http.StatusFound)
}
// getAuthSettings handles GET /api/auth/settings.
func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) {
as, err := s.store.GetAuthSettings()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to get auth settings: "+err.Error())
return
}
// Mask the client secret for the response.
if as.OIDCClientSecret != "" {
as.OIDCClientSecret = "********"
}
respondJSON(w, http.StatusOK, as)
}
// updateAuthSettings handles PUT /api/auth/settings.
func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) {
var req store.AuthSettings
if !decodeJSON(w, r, &req) {
return
}
if req.AuthMode != "local" && req.AuthMode != "oidc" {
respondError(w, http.StatusBadRequest, "auth_mode must be 'local' or 'oidc'")
return
}
// If client secret is masked, preserve the existing value.
if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" {
existing, err := s.store.GetAuthSettings()
if err == nil {
req.OIDCClientSecret = existing.OIDCClientSecret
}
}
if err := s.store.UpdateAuthSettings(req); err != nil {
respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.Error())
return
}
// Re-initialize OIDC provider if mode is oidc and config is present.
if req.AuthMode == "oidc" && req.OIDCIssuerURL != "" && req.OIDCClientID != "" {
s.initOIDCProvider(r.Context(), req)
}
respondJSON(w, http.StatusOK, req)
}
// listUsers handles GET /api/auth/users.
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
users, err := s.store.GetAllUsers()
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to list users: "+err.Error())
return
}
respondJSON(w, http.StatusOK, users)
}
// createUser handles POST /api/auth/users.
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
Email string `json:"email"`
Role string `json:"role"`
}
if !decodeJSON(w, r, &req) {
return
}
if req.Username == "" || req.Password == "" {
respondError(w, http.StatusBadRequest, "username and password are required")
return
}
if req.Role == "" {
req.Role = "viewer"
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error())
return
}
user, err := s.store.CreateUser(store.User{
Username: req.Username,
PasswordHash: hash,
Email: req.Email,
Role: req.Role,
})
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
return
}
respondJSON(w, http.StatusCreated, user)
}
// deleteUser handles DELETE /api/auth/users/{uid}.
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "uid")
// Prevent deleting the last admin.
user, err := s.store.GetUserByID(id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
respondNotFound(w, "user")
return
}
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
return
}
if user.Role == "admin" {
users, err := s.store.GetAllUsers()
if err == nil {
adminCount := 0
for _, u := range users {
if u.Role == "admin" {
adminCount++
}
}
if adminCount <= 1 {
respondError(w, http.StatusBadRequest, "cannot delete the last admin user")
return
}
}
}
if err := s.store.DeleteUser(id); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
}
+21
View File
@@ -0,0 +1,21 @@
package api
import (
"net/http"
"github.com/alexei/docker-watcher/internal/config"
)
// exportConfig handles GET /api/config/export — downloads current state as YAML.
func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) {
data, err := config.ExportConfig(s.store)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to export config: "+err.Error())
return
}
w.Header().Set("Content-Type", "application/x-yaml")
w.Header().Set("Content-Disposition", "attachment; filename=docker-watcher.yaml")
w.WriteHeader(http.StatusOK)
w.Write(data)
}
+2 -2
View File
@@ -1,7 +1,7 @@
package api
import (
"log"
"log/slog"
"net/http"
"strconv"
"strings"
@@ -61,7 +61,7 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
// Split image:tag for the pull call.
imageRef, tag := splitImageTag(req.Image)
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
log.Printf("[api] pull image %s for inspect: %v", req.Image, err)
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
// Try to inspect anyway in case the image is already local.
}
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
@@ -103,7 +103,7 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
// Remove the Docker container if it has one.
if inst.ContainerID != "" {
if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil {
log.Printf("[api] remove container %s: %v", inst.ContainerID, err)
slog.Error("remove container", "container_id", inst.ContainerID, "error", err)
}
}
@@ -175,7 +175,7 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
// Update status in store.
if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil {
log.Printf("[api] update instance %s status to %s: %v", instanceID, newStatus, err)
slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "error", err)
}
respondJSON(w, http.StatusOK, map[string]string{
+8 -3
View File
@@ -1,7 +1,7 @@
package api
import (
"log"
"log/slog"
"net/http"
"runtime/debug"
"time"
@@ -16,7 +16,12 @@ func logging(next http.Handler) http.Handler {
next.ServeHTTP(wrapped, r)
log.Printf("[api] %s %s %d %s", r.Method, r.URL.Path, wrapped.status, time.Since(start))
slog.Info("http request",
"method", r.Method,
"path", r.URL.Path,
"status", wrapped.status,
"duration", time.Since(start).String(),
)
})
}
@@ -25,7 +30,7 @@ func recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if err := recover(); err != nil {
log.Printf("[api] panic: %v\n%s", err, debug.Stack())
slog.Error("panic recovered", "error", err, "stack", string(debug.Stack()))
respondError(w, http.StatusInternalServerError, "internal server error")
}
}()
+3 -3
View File
@@ -2,7 +2,7 @@ package api
import (
"encoding/json"
"log"
"log/slog"
"net/http"
)
@@ -18,7 +18,7 @@ func respondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil {
log.Printf("[api] encode response: %v", err)
slog.Error("encode response", "error", err)
}
}
@@ -27,7 +27,7 @@ func respondError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(envelope{Success: false, Error: msg}); err != nil {
log.Printf("[api] encode error response: %v", err)
slog.Error("encode error response", "error", err)
}
}
+118 -66
View File
@@ -1,8 +1,12 @@
package api
import (
"context"
"log/slog"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/auth"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/store"
@@ -11,12 +15,14 @@ import (
// Server holds all dependencies for the API layer.
type Server struct {
store *store.Store
docker *docker.Client
deployer DeployTriggerer
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
store *store.Store
docker *docker.Client
deployer DeployTriggerer
webhook *webhook.Handler
eventBus *events.Bus
encKey [32]byte
localAuth *auth.LocalAuth
oidcProvider *auth.OIDCProvider
}
// NewServer creates a new API Server with all required dependencies.
@@ -28,19 +34,44 @@ func NewServer(
eventBus *events.Bus,
encKey [32]byte,
) *Server {
return &Server{
store: st,
docker: dockerClient,
deployer: deployer,
webhook: webhookHandler,
eventBus: eventBus,
encKey: encKey,
localAuth := auth.NewLocalAuth(encKey)
s := &Server{
store: st,
docker: dockerClient,
deployer: deployer,
webhook: webhookHandler,
eventBus: eventBus,
encKey: encKey,
localAuth: localAuth,
}
// Try to initialize OIDC provider from stored settings.
authSettings, err := st.GetAuthSettings()
if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" {
s.initOIDCProvider(context.Background(), authSettings)
}
return s
}
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{
IssuerURL: as.OIDCIssuerURL,
ClientID: as.OIDCClientID,
ClientSecret: as.OIDCClientSecret,
RedirectURL: as.OIDCRedirectURL,
})
if err != nil {
slog.Warn("failed to initialize OIDC provider", "error", err)
return
}
s.oidcProvider = provider
slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL)
}
// Router returns a chi router with all API routes mounted.
// NOTE: Authentication middleware is added in Phase 12 (Hardening).
// Until then, this API should only be exposed on trusted networks.
func (s *Server) Router() chi.Router {
r := chi.NewRouter()
@@ -51,59 +82,80 @@ func (s *Server) Router() chi.Router {
r.Use(jsonContentType)
r.Route("/api", func(r chi.Router) {
// Project endpoints.
r.Get("/projects", s.listProjects)
r.Post("/projects", s.createProject)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
r.Put("/", s.updateProject)
r.Delete("/", s.deleteProject)
// Public auth endpoints (no auth required).
r.Post("/auth/login", s.login)
r.Get("/auth/oidc/login", s.oidcLogin)
r.Get("/auth/oidc/callback", s.oidcCallback)
// Stage endpoints.
r.Post("/stages", s.createStage)
r.Put("/stages/{stage}", s.updateStage)
r.Delete("/stages/{stage}", s.deleteStage)
// Instance endpoints.
r.Get("/stages/{stage}/instances", s.listInstances)
r.Post("/stages/{stage}/instances", s.deployInstance)
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
// Instance control endpoints.
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
})
// Deploy endpoints.
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
// SSE endpoint for real-time instance status and deploy events.
r.Get("/events", s.streamEvents)
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
// Registry endpoints.
r.Get("/registries", s.listRegistries)
r.Post("/registries", s.createRegistry)
r.Route("/registries/{id}", func(r chi.Router) {
r.Put("/", s.updateRegistry)
r.Delete("/", s.deleteRegistry)
r.Post("/test", s.testRegistry)
r.Get("/tags/*", s.listRegistryTags)
})
// Settings endpoints.
r.Get("/settings", s.getSettings)
r.Put("/settings", s.updateSettings)
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/regenerate", s.regenerateWebhookSecret)
// Webhook handler (from webhook package).
// Webhook handler (uses its own secret-based auth).
r.Mount("/webhook", s.webhook.Route())
// Config export (public endpoint, useful for backup).
r.Get("/config/export", s.exportConfig)
// Protected routes: require valid JWT.
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(s.localAuth))
// Auth management.
r.Get("/auth/me", s.currentUser)
r.Get("/auth/settings", s.getAuthSettings)
r.Put("/auth/settings", s.updateAuthSettings)
r.Get("/auth/users", s.listUsers)
r.Post("/auth/users", s.createUser)
r.Delete("/auth/users/{uid}", s.deleteUser)
// Project endpoints.
r.Get("/projects", s.listProjects)
r.Post("/projects", s.createProject)
r.Route("/projects/{id}", func(r chi.Router) {
r.Get("/", s.getProject)
r.Put("/", s.updateProject)
r.Delete("/", s.deleteProject)
// Stage endpoints.
r.Post("/stages", s.createStage)
r.Put("/stages/{stage}", s.updateStage)
r.Delete("/stages/{stage}", s.deleteStage)
// Instance endpoints.
r.Get("/stages/{stage}/instances", s.listInstances)
r.Post("/stages/{stage}/instances", s.deployInstance)
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
// Instance control endpoints.
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
})
// Deploy endpoints.
r.Get("/deploys", s.listDeploys)
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
// SSE endpoint for real-time instance status and deploy events.
r.Get("/events", s.streamEvents)
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
// Registry endpoints.
r.Get("/registries", s.listRegistries)
r.Post("/registries", s.createRegistry)
r.Route("/registries/{id}", func(r chi.Router) {
r.Put("/", s.updateRegistry)
r.Delete("/", s.deleteRegistry)
r.Post("/test", s.testRegistry)
r.Get("/tags/*", s.listRegistryTags)
})
// Settings endpoints.
r.Get("/settings", s.getSettings)
r.Put("/settings", s.updateSettings)
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/regenerate", s.regenerateWebhookSecret)
})
})
return r
+3 -3
View File
@@ -4,7 +4,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"log/slog"
"net/http"
"strings"
@@ -68,7 +68,7 @@ func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
// Send existing logs first.
existingLogs, err := s.store.GetDeployLogs(deployID)
if err != nil {
log.Printf("[sse] failed to get existing deploy logs: %v", err)
slog.Error("get existing deploy logs", "error", err)
} else {
for _, entry := range existingLogs {
writeSSE(w, flusher, events.Event{
@@ -174,7 +174,7 @@ func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
data, err := json.Marshal(evt)
if err != nil {
log.Printf("[sse] marshal event: %v", err)
slog.Error("marshal SSE event", "error", err)
return
}
fmt.Fprintf(w, "data: %s\n\n", data)
+111
View File
@@ -0,0 +1,111 @@
package auth
import (
"crypto/hmac"
"crypto/sha256"
"errors"
"fmt"
"time"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt"
)
// ErrInvalidCredentials indicates that the supplied username/password is wrong.
var ErrInvalidCredentials = errors.New("invalid credentials")
// ErrInvalidToken indicates that the JWT is invalid or expired.
var ErrInvalidToken = errors.New("invalid or expired token")
// TokenExpiry is the lifetime of a JWT session token.
const TokenExpiry = 24 * time.Hour
// jwtClaims extends jwt.RegisteredClaims with application-specific fields.
type jwtClaims struct {
jwt.RegisteredClaims
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
}
// LocalAuth handles password hashing and JWT token management for local auth mode.
type LocalAuth struct {
jwtSecret []byte
}
// NewLocalAuth creates a LocalAuth deriving the JWT signing key from the encryption key
// using HMAC-SHA256.
func NewLocalAuth(encKey [32]byte) *LocalAuth {
mac := hmac.New(sha256.New, encKey[:])
mac.Write([]byte("docker-watcher-jwt-secret"))
return &LocalAuth{
jwtSecret: mac.Sum(nil),
}
}
// HashPassword hashes a plaintext password using bcrypt.
func HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("hash password: %w", err)
}
return string(hash), nil
}
// CheckPassword compares a plaintext password against a bcrypt hash.
func CheckPassword(hash, password string) error {
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)); err != nil {
return ErrInvalidCredentials
}
return nil
}
// GenerateToken creates a signed JWT for the given user claims.
func (la *LocalAuth) GenerateToken(claims Claims) (SessionToken, error) {
expiresAt := time.Now().Add(TokenExpiry)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwtClaims{
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(expiresAt),
IssuedAt: jwt.NewNumericDate(time.Now()),
Issuer: "docker-watcher",
},
UserID: claims.UserID,
Username: claims.Username,
Role: claims.Role,
})
signed, err := token.SignedString(la.jwtSecret)
if err != nil {
return SessionToken{}, fmt.Errorf("sign token: %w", err)
}
return SessionToken{
Token: signed,
ExpiresAt: expiresAt,
}, nil
}
// ValidateToken parses and validates a JWT, returning the embedded claims.
func (la *LocalAuth) ValidateToken(tokenString string) (Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &jwtClaims{}, func(token *jwt.Token) (any, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return la.jwtSecret, nil
})
if err != nil {
return Claims{}, ErrInvalidToken
}
claims, ok := token.Claims.(*jwtClaims)
if !ok || !token.Valid {
return Claims{}, ErrInvalidToken
}
return Claims{
UserID: claims.UserID,
Username: claims.Username,
Role: claims.Role,
}, nil
}
+68
View File
@@ -0,0 +1,68 @@
package auth
import (
"context"
"net/http"
"strings"
)
// contextKey is the type for context value keys used by the auth package.
type contextKey string
const claimsKey contextKey = "auth_claims"
// Middleware returns an HTTP middleware that protects routes by requiring a valid JWT.
// It extracts the token from the Authorization header (Bearer scheme) or the "token"
// query parameter (for SSE connections).
// Unauthenticated requests receive a 401 JSON response.
func Middleware(la *LocalAuth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tokenStr := extractToken(r)
if tokenStr == "" {
http.Error(w, `{"success":false,"error":"authentication required"}`, http.StatusUnauthorized)
return
}
claims, err := la.ValidateToken(tokenStr)
if err != nil {
http.Error(w, `{"success":false,"error":"invalid or expired token"}`, http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// AdminOnly returns an HTTP middleware that requires the authenticated user to have
// the "admin" role.
func AdminOnly(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
claims, ok := ClaimsFromContext(r.Context())
if !ok || claims.Role != "admin" {
http.Error(w, `{"success":false,"error":"admin access required"}`, http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
// ClaimsFromContext retrieves the authenticated user's claims from the request context.
func ClaimsFromContext(ctx context.Context) (Claims, bool) {
claims, ok := ctx.Value(claimsKey).(Claims)
return claims, ok
}
// extractToken gets the JWT from the Authorization header or "token" query param.
func extractToken(r *http.Request) string {
// Try Authorization: Bearer <token>
authHeader := r.Header.Get("Authorization")
if strings.HasPrefix(authHeader, "Bearer ") {
return strings.TrimPrefix(authHeader, "Bearer ")
}
// Fall back to query parameter (used by SSE and browser-based connections).
return r.URL.Query().Get("token")
}
+42
View File
@@ -0,0 +1,42 @@
package auth
import "time"
// User represents an authenticated user stored in the database.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Email string `json:"email"`
Role string `json:"role"` // admin, viewer
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AuthSettings holds the authentication configuration (single-row pattern).
type AuthSettings struct {
AuthMode string `json:"auth_mode"` // local, oidc
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"-"`
OIDCIssuerURL string `json:"oidc_issuer_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
}
// Claims represents the JWT token claims.
type Claims struct {
UserID string `json:"user_id"`
Username string `json:"username"`
Role string `json:"role"`
}
// SessionToken is the response sent to the client after successful authentication.
type SessionToken struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
// LoginRequest is the expected JSON body for the login endpoint.
type LoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
+87
View File
@@ -0,0 +1,87 @@
package auth
import (
"context"
"fmt"
"github.com/coreos/go-oidc/v3/oidc"
"golang.org/x/oauth2"
)
// OIDCProvider wraps an OIDC provider and OAuth2 configuration.
type OIDCProvider struct {
provider *oidc.Provider
oauth2Config oauth2.Config
verifier *oidc.IDTokenVerifier
}
// OIDCConfig holds the configuration needed to set up an OIDC provider.
type OIDCConfig struct {
IssuerURL string
ClientID string
ClientSecret string
RedirectURL string
}
// OIDCUserInfo represents the user information extracted from an OIDC ID token.
type OIDCUserInfo struct {
Subject string `json:"sub"`
Email string `json:"email"`
Username string `json:"preferred_username"`
Name string `json:"name"`
}
// NewOIDCProvider initializes an OIDC provider using the discovery URL.
func NewOIDCProvider(ctx context.Context, cfg OIDCConfig) (*OIDCProvider, error) {
provider, err := oidc.NewProvider(ctx, cfg.IssuerURL)
if err != nil {
return nil, fmt.Errorf("create oidc provider: %w", err)
}
oauth2Config := oauth2.Config{
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
RedirectURL: cfg.RedirectURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
}
verifier := provider.Verifier(&oidc.Config{ClientID: cfg.ClientID})
return &OIDCProvider{
provider: provider,
oauth2Config: oauth2Config,
verifier: verifier,
}, nil
}
// AuthCodeURL returns the URL to redirect the user to for OIDC authentication.
func (op *OIDCProvider) AuthCodeURL(state string) string {
return op.oauth2Config.AuthCodeURL(state)
}
// Exchange trades an authorization code for tokens and returns the user info
// extracted from the ID token.
func (op *OIDCProvider) Exchange(ctx context.Context, code string) (OIDCUserInfo, error) {
token, err := op.oauth2Config.Exchange(ctx, code)
if err != nil {
return OIDCUserInfo{}, fmt.Errorf("exchange code: %w", err)
}
rawIDToken, ok := token.Extra("id_token").(string)
if !ok {
return OIDCUserInfo{}, fmt.Errorf("no id_token in response")
}
idToken, err := op.verifier.Verify(ctx, rawIDToken)
if err != nil {
return OIDCUserInfo{}, fmt.Errorf("verify id_token: %w", err)
}
var userInfo OIDCUserInfo
if err := idToken.Claims(&userInfo); err != nil {
return OIDCUserInfo{}, fmt.Errorf("parse id_token claims: %w", err)
}
return userInfo, nil
}
+118
View File
@@ -0,0 +1,118 @@
package config
import (
"encoding/json"
"fmt"
"github.com/alexei/docker-watcher/internal/store"
"gopkg.in/yaml.v3"
)
// ExportConfig reads the current database state and produces a SeedConfig YAML
// representation. Credential fields (tokens, passwords) are exported as placeholder
// strings since they are encrypted in the database.
func ExportConfig(db *store.Store) ([]byte, error) {
cfg, err := buildSeedConfig(db)
if err != nil {
return nil, fmt.Errorf("build seed config: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal yaml: %w", err)
}
return data, nil
}
// buildSeedConfig constructs a SeedConfig from the current database state.
func buildSeedConfig(db *store.Store) (SeedConfig, error) {
settings, err := db.GetSettings()
if err != nil {
return SeedConfig{}, fmt.Errorf("get settings: %w", err)
}
registries, err := db.GetAllRegistries()
if err != nil {
return SeedConfig{}, fmt.Errorf("get registries: %w", err)
}
projects, err := db.GetAllProjects()
if err != nil {
return SeedConfig{}, fmt.Errorf("get projects: %w", err)
}
cfg := SeedConfig{
Global: GlobalConfig{
Domain: settings.Domain,
ServerIP: settings.ServerIP,
Network: settings.Network,
SubdomainPattern: settings.SubdomainPattern,
NotificationURL: settings.NotificationURL,
Npm: NpmConfig{
URL: settings.NpmURL,
Email: settings.NpmEmail,
Password: "CHANGE_ME", // Encrypted value, export placeholder.
},
},
Registries: make(map[string]RegistryDef),
Projects: make(map[string]ProjectDef),
}
for _, reg := range registries {
cfg.Registries[reg.Name] = RegistryDef{
URL: reg.URL,
Type: reg.Type,
Token: "CHANGE_ME", // Encrypted value, export placeholder.
}
}
for _, proj := range projects {
stages, err := db.GetStagesByProjectID(proj.ID)
if err != nil {
return SeedConfig{}, fmt.Errorf("get stages for project %s: %w", proj.Name, err)
}
stageDefs := make(map[string]StageDef)
for _, st := range stages {
stageDefs[st.Name] = StageDef{
TagPattern: st.TagPattern,
AutoDeploy: st.AutoDeploy,
MaxInstances: st.MaxInstances,
Confirm: st.Confirm,
PromoteFrom: st.PromoteFrom,
Subdomain: st.Subdomain,
}
}
envMap := parseJSONMap(proj.Env)
volMap := parseJSONMap(proj.Volumes)
cfg.Projects[proj.Name] = ProjectDef{
Registry: proj.Registry,
Image: proj.Image,
Port: proj.Port,
Healthcheck: proj.Healthcheck,
Env: envMap,
Volumes: volMap,
Stages: stageDefs,
}
}
return cfg, nil
}
// parseJSONMap safely parses a JSON-encoded map string. Returns nil on failure.
func parseJSONMap(jsonStr string) map[string]string {
if jsonStr == "" || jsonStr == "{}" {
return nil
}
var m map[string]string
if err := json.Unmarshal([]byte(jsonStr), &m); err != nil {
return nil
}
if len(m) == 0 {
return nil
}
return m
}
+173
View File
@@ -0,0 +1,173 @@
package deployer
import (
"context"
"fmt"
"log/slog"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/store"
"github.com/google/uuid"
)
// blueGreenDeploy performs a zero-downtime deployment:
// 1. Start new container (green)
// 2. Health check green
// 3. Swap NPM proxy to point to green
// 4. Stop old container (blue)
//
// If the new container fails health check, it is removed and the old one stays.
func (d *Deployer) blueGreenDeploy(
ctx context.Context,
project store.Project,
stage store.Stage,
settings store.Settings,
deployID string,
imageTag string,
) (string, int, string, error) {
// Find existing running instance for this stage (the "blue" instance).
existingInstances, err := d.store.GetInstancesByStageID(stage.ID)
if err != nil {
return "", 0, "", fmt.Errorf("get existing instances: %w", err)
}
var blueInstance *store.Instance
for _, inst := range existingInstances {
if inst.Status == "running" {
instCopy := inst
blueInstance = &instCopy
break
}
}
// Step 1: Pull image.
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
d.logDeploy(deployID, fmt.Sprintf("Blue-green: pulling image %s:%s", project.Image, imageTag), "info")
authConfig, err := d.buildRegistryAuth(project)
if err != nil {
return "", 0, "", fmt.Errorf("build registry auth: %w", err)
}
if err := d.docker.PullImage(ctx, project.Image, imageTag, authConfig); err != nil {
return "", 0, "", fmt.Errorf("pull image: %w", err)
}
d.logDeploy(deployID, "Image pulled successfully", "info")
// Step 2: Ensure network.
networkID, err := d.docker.EnsureNetwork(ctx, settings.Network)
if err != nil {
return "", 0, "", fmt.Errorf("ensure network: %w", err)
}
// Step 3: Create and start green container.
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
instanceID := uuid.New().String()
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.parseEnvVars(project.Env)
containerCfg := docker.ContainerConfig{
Name: containerName,
Image: project.Image + ":" + imageTag,
Env: envVars,
ExposedPorts: []string{portStr},
NetworkName: settings.Network,
NetworkID: networkID,
Project: project.Name,
Stage: stage.Name,
InstanceID: instanceID,
}
d.logDeploy(deployID, fmt.Sprintf("Blue-green: creating green container %s", containerName), "info")
containerID, err := d.docker.CreateContainer(ctx, containerCfg)
if err != nil {
return "", 0, instanceID, fmt.Errorf("create container: %w", err)
}
// Create instance record.
inst, err := d.store.CreateInstanceWithID(store.Instance{
ID: instanceID,
StageID: stage.ID,
ProjectID: project.ID,
ContainerID: containerID,
ImageTag: imageTag,
Subdomain: subdomain,
Status: "stopped",
Port: project.Port,
})
if err != nil {
return containerID, 0, instanceID, fmt.Errorf("create instance record: %w", err)
}
instanceID = inst.ID
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
slog.Warn("link deploy to instance", "error", err)
}
d.logDeploy(deployID, fmt.Sprintf("Blue-green: starting green container %s", containerName), "info")
if err := d.docker.StartContainer(ctx, containerID); err != nil {
return containerID, 0, instanceID, fmt.Errorf("start container: %w", err)
}
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
slog.Warn("update instance status", "error", err)
}
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
// Step 4: Health check the green container.
if project.Healthcheck != "" {
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
healthURL := fmt.Sprintf("http://%s:%d%s", containerName, project.Port, project.Healthcheck)
d.logDeploy(deployID, fmt.Sprintf("Blue-green: health checking green at %s", healthURL), "info")
if err := d.health.Check(ctx, healthURL); err != nil {
return containerID, 0, instanceID, fmt.Errorf("health check green: %w", err)
}
d.logDeploy(deployID, "Blue-green: green health check passed", "info")
}
// Step 5: Swap NPM proxy to green.
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
npmProxyID, err := d.configureProxy(ctx, deployID, settings, containerName, project.Port, subdomain)
if err != nil {
return containerID, 0, instanceID, fmt.Errorf("configure proxy: %w", err)
}
inst.NpmProxyID = npmProxyID
inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil {
slog.Warn("update instance with proxy ID", "error", err)
}
d.logDeploy(deployID, "Blue-green: proxy swapped to green container", "info")
// Step 6: Stop the blue container.
if blueInstance != nil {
d.logDeploy(deployID, fmt.Sprintf("Blue-green: stopping blue instance %s (tag: %s)", blueInstance.ID, blueInstance.ImageTag), "info")
if err := d.removeInstance(ctx, *blueInstance, settings); err != nil {
// Non-fatal: log but continue. Green is already serving traffic.
d.logDeploy(deployID, fmt.Sprintf("Blue-green: warning: failed to remove blue instance: %v", err), "warn")
} else {
d.logDeploy(deployID, "Blue-green: blue instance removed", "info")
}
}
return containerID, npmProxyID, instanceID, nil
}
+60 -18
View File
@@ -4,8 +4,10 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"sort"
"sync"
"sync/atomic"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/docker"
@@ -28,6 +30,10 @@ type Deployer struct {
notifier *notify.Notifier
eventBus EventPublisher
encKey [32]byte
// Graceful shutdown: tracks in-progress deploys.
activeWg sync.WaitGroup
shuttingDown atomic.Bool
}
// EventPublisher is the interface for publishing events to the event bus.
@@ -56,10 +62,25 @@ func New(
}
}
// Drain waits for all in-progress deploys to complete. Call this during graceful shutdown.
func (d *Deployer) Drain() {
d.shuttingDown.Store(true)
slog.Info("deployer: draining in-progress deploys")
d.activeWg.Wait()
slog.Info("deployer: all deploys drained")
}
// TriggerDeploy is the main entry point for deployments. It orchestrates the full flow:
// pull image -> create container -> start -> configure proxy -> health check.
// On failure, it rolls back (removes container, deletes proxy host, updates status).
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
if d.shuttingDown.Load() {
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
}
d.activeWg.Add(1)
defer d.activeWg.Done()
// Load project and stage from store.
project, err := d.store.GetProjectByID(projectID)
if err != nil {
@@ -71,6 +92,11 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
return fmt.Errorf("get stage: %w", err)
}
// Validate promote_from constraint.
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
return fmt.Errorf("promote validation: %w", err)
}
settings, err := d.store.GetSettings()
if err != nil {
return fmt.Errorf("get settings: %w", err)
@@ -87,6 +113,12 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
return fmt.Errorf("create deploy record: %w", err)
}
slog.Info("starting deploy",
"deploy_id", deploy.ID,
"project", project.Name,
"stage", stage.Name,
"tag", imageTag,
)
d.logDeploy(deploy.ID, fmt.Sprintf("Starting deploy of %s:%s for project %s, stage %s", project.Image, imageTag, project.Name, stage.Name), "info")
// Enforce max_instances before deploying.
@@ -95,8 +127,18 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
// Non-fatal: continue with deploy.
}
// Execute the deploy pipeline. Track state for rollback.
containerID, npmProxyID, instanceID, deployErr := d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag)
// Choose deploy strategy: blue-green if stage has max_instances=1 and an existing instance.
var containerID string
var npmProxyID int
var instanceID string
var deployErr error
if stage.MaxInstances == 1 {
containerID, npmProxyID, instanceID, deployErr = d.blueGreenDeploy(ctx, project, stage, settings, deploy.ID, imageTag)
} else {
// Execute the standard deploy pipeline. Track state for rollback.
containerID, npmProxyID, instanceID, deployErr = d.executeDeploy(ctx, project, stage, settings, deploy.ID, imageTag)
}
if deployErr != nil {
d.logDeploy(deploy.ID, fmt.Sprintf("Deploy failed: %v", deployErr), "error")
@@ -116,7 +158,7 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
// Mark deploy as successful.
if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil {
log.Printf("deployer: update deploy status to success: %v", err)
slog.Warn("update deploy status to success", "error", err)
}
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "success", "")
@@ -153,7 +195,7 @@ func (d *Deployer) executeDeploy(
// Step 1: Pull image.
if err := d.store.UpdateDeployStatus(deployID, "pulling", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "pulling", "")
d.logDeploy(deployID, fmt.Sprintf("Pulling image %s:%s", project.Image, imageTag), "info")
@@ -177,7 +219,7 @@ func (d *Deployer) executeDeploy(
// Step 3: Create and start container.
if err := d.store.UpdateDeployStatus(deployID, "starting", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "starting", "")
@@ -226,7 +268,7 @@ func (d *Deployer) executeDeploy(
// Link deploy to instance.
if err := d.store.SetDeployInstanceID(deployID, instanceID); err != nil {
log.Printf("deployer: link deploy to instance: %v", err)
slog.Warn("link deploy to instance", "error", err)
}
d.logDeploy(deployID, fmt.Sprintf("Starting container %s", containerName), "info")
@@ -235,14 +277,14 @@ func (d *Deployer) executeDeploy(
}
if err := d.store.UpdateInstanceStatus(instanceID, "running"); err != nil {
log.Printf("deployer: update instance status to running: %v", err)
slog.Warn("update instance status to running", "error", err)
}
d.publishInstanceStatus(instanceID, project.ID, stage.ID, "running")
d.logDeploy(deployID, "Container started", "info")
// Step 4: Configure NPM proxy.
if err := d.store.UpdateDeployStatus(deployID, "configuring_proxy", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "configuring_proxy", "")
@@ -255,13 +297,13 @@ func (d *Deployer) executeDeploy(
inst.NpmProxyID = npmProxyID
inst.Subdomain = subdomain
if err := d.store.UpdateInstance(inst); err != nil {
log.Printf("deployer: update instance with proxy ID: %v", err)
slog.Warn("update instance with proxy ID", "error", err)
}
// Step 5: Health check.
if project.Healthcheck != "" {
if err := d.store.UpdateDeployStatus(deployID, "health_checking", ""); err != nil {
log.Printf("deployer: update deploy status: %v", err)
slog.Warn("update deploy status", "error", err)
}
d.publishDeployStatus(deployID, project.ID, stage.ID, imageTag, "health_checking", "")
@@ -390,13 +432,13 @@ func (d *Deployer) enforceMaxInstances(ctx context.Context, stage store.Stage, d
func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, settings store.Settings) error {
// Mark as removing.
if err := d.store.UpdateInstanceStatus(inst.ID, "removing"); err != nil {
log.Printf("deployer: update instance %s status to removing: %v", inst.ID, err)
slog.Warn("update instance status to removing", "instance_id", inst.ID, "error", err)
}
// Remove Docker container.
if inst.ContainerID != "" {
if err := d.docker.RemoveContainer(ctx, inst.ContainerID, true); err != nil {
log.Printf("deployer: remove container %s: %v", inst.ContainerID, err)
slog.Warn("remove container", "container_id", inst.ContainerID, "error", err)
}
}
@@ -404,11 +446,11 @@ func (d *Deployer) removeInstance(ctx context.Context, inst store.Instance, sett
if inst.NpmProxyID > 0 {
npmPassword, err := d.decryptNpmPassword(settings.NpmPassword)
if err != nil {
log.Printf("deployer: decrypt npm password for proxy cleanup: %v", err)
slog.Warn("decrypt npm password for proxy cleanup", "error", err)
} else if authErr := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); authErr != nil {
log.Printf("deployer: authenticate npm for proxy cleanup: %v", authErr)
slog.Warn("authenticate npm for proxy cleanup", "error", authErr)
} else if delErr := d.npm.DeleteProxyHost(ctx, inst.NpmProxyID); delErr != nil {
log.Printf("deployer: delete proxy host %d: %v", inst.NpmProxyID, delErr)
slog.Warn("delete proxy host", "proxy_id", inst.NpmProxyID, "error", delErr)
}
}
@@ -471,7 +513,7 @@ func (d *Deployer) parseEnvVars(envJSON string) []string {
var envMap map[string]string
if err := json.Unmarshal([]byte(envJSON), &envMap); err != nil {
log.Printf("deployer: parse env vars: %v", err)
slog.Warn("parse env vars", "error", err)
return nil
}
@@ -486,7 +528,7 @@ func (d *Deployer) parseEnvVars(envJSON string) []string {
// Errors are logged to stderr but not propagated.
func (d *Deployer) logDeploy(deployID, message, level string) {
if err := d.store.AppendDeployLog(deployID, message, level); err != nil {
log.Printf("deployer: append deploy log: %v", err)
slog.Warn("append deploy log", "error", err)
}
if d.eventBus != nil {
d.eventBus.Publish(events.Event{
+49
View File
@@ -0,0 +1,49 @@
package deployer
import (
"fmt"
"github.com/alexei/docker-watcher/internal/store"
)
// validatePromoteFrom checks that a tag is running in the promote_from stage
// before allowing it to be deployed to the target stage.
// Returns nil if no promote_from is configured or if the tag is eligible.
func (d *Deployer) validatePromoteFrom(stage store.Stage, imageTag string) error {
if stage.PromoteFrom == "" {
return nil
}
// Look up the source stage by name within the same project.
stages, err := d.store.GetStagesByProjectID(stage.ProjectID)
if err != nil {
return fmt.Errorf("get stages for project: %w", err)
}
var sourceStage *store.Stage
for _, s := range stages {
if s.Name == stage.PromoteFrom {
sCopy := s
sourceStage = &sCopy
break
}
}
if sourceStage == nil {
return fmt.Errorf("promote_from stage %q not found in project", stage.PromoteFrom)
}
// Check if the tag is running in the source stage.
instances, err := d.store.GetInstancesByStageID(sourceStage.ID)
if err != nil {
return fmt.Errorf("get instances for source stage: %w", err)
}
for _, inst := range instances {
if inst.ImageTag == imageTag && (inst.Status == "running" || inst.Status == "stopped") {
return nil // Tag found in source stage, promotion is allowed.
}
}
return fmt.Errorf("tag %q is not running in stage %q; promotion denied", imageTag, stage.PromoteFrom)
}
+8 -8
View File
@@ -3,7 +3,7 @@ package deployer
import (
"context"
"fmt"
"log"
"log/slog"
)
// rollback cleans up a failed deployment by removing the container,
@@ -15,7 +15,7 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
// Remove the container if it was created.
if containerID != "" {
if err := d.docker.RemoveContainer(ctx, containerID, true); err != nil {
log.Printf("rollback: remove container %s: %v", containerID, err)
slog.Warn("rollback: remove container", "container_id", containerID, "error", err)
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to remove container: %v", err), "error")
} else {
d.logDeploy(deployID, "Rollback: container removed", "info")
@@ -26,16 +26,16 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
if npmProxyID > 0 {
settings, err := d.store.GetSettings()
if err != nil {
log.Printf("rollback: get settings for npm auth: %v", err)
slog.Warn("rollback: get settings for npm auth", "error", err)
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to get settings for proxy cleanup: %v", err), "error")
} else if npmPassword, err := d.decryptNpmPassword(settings.NpmPassword); err != nil {
log.Printf("rollback: decrypt npm password: %v", err)
slog.Warn("rollback: decrypt npm password", "error", err)
d.logDeploy(deployID, "Rollback: failed to decrypt NPM password for proxy cleanup", "error")
} else if err := d.npm.Authenticate(ctx, settings.NpmEmail, npmPassword); err != nil {
log.Printf("rollback: authenticate npm: %v", err)
slog.Warn("rollback: authenticate npm", "error", err)
d.logDeploy(deployID, "Rollback: failed to authenticate NPM for proxy cleanup", "error")
} else if err := d.npm.DeleteProxyHost(ctx, npmProxyID); err != nil {
log.Printf("rollback: delete proxy host %d: %v", npmProxyID, err)
slog.Warn("rollback: delete proxy host", "proxy_id", npmProxyID, "error", err)
d.logDeploy(deployID, fmt.Sprintf("Rollback: failed to delete proxy host: %v", err), "error")
} else {
d.logDeploy(deployID, "Rollback: proxy host deleted", "info")
@@ -45,13 +45,13 @@ func (d *Deployer) rollback(ctx context.Context, deployID string, containerID st
// Update instance status to failed if it was created.
if instanceID != "" {
if err := d.store.UpdateInstanceStatus(instanceID, "failed"); err != nil {
log.Printf("rollback: update instance %s status: %v", instanceID, err)
slog.Warn("rollback: update instance status", "instance_id", instanceID, "error", err)
}
}
// Mark deploy as rolled back.
if err := d.store.UpdateDeployStatus(deployID, "rolled_back", "deployment failed, rolled back"); err != nil {
log.Printf("rollback: update deploy %s status: %v", deployID, err)
slog.Warn("rollback: update deploy status", "deploy_id", deployID, "error", err)
}
d.logDeploy(deployID, "Rollback complete", "info")
+34
View File
@@ -0,0 +1,34 @@
package logging
import (
"io"
"log/slog"
"os"
)
// Setup initializes the global structured JSON logger.
// It replaces the default slog handler with a JSON handler writing to stdout.
func Setup() {
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))
}
// SetupWithWriter initializes the global structured JSON logger writing to the given writer.
func SetupWithWriter(w io.Writer) {
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))
}
// DeployContext returns a logger enriched with deploy-specific attributes.
func DeployContext(project, stage, tag, instanceID string) *slog.Logger {
return slog.With(
slog.String("project", project),
slog.String("stage", stage),
slog.String("tag", tag),
slog.String("instance_id", instanceID),
)
}
+22
View File
@@ -156,8 +156,30 @@ CREATE TABLE IF NOT EXISTS poll_states (
last_polled TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
role TEXT NOT NULL DEFAULT 'viewer',
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS auth_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
auth_mode TEXT NOT NULL DEFAULT 'local',
oidc_client_id TEXT NOT NULL DEFAULT '',
oidc_client_secret TEXT NOT NULL DEFAULT '',
oidc_issuer_url TEXT NOT NULL DEFAULT '',
oidc_redirect_url TEXT NOT NULL DEFAULT ''
);
-- Seed the settings row if it does not exist.
INSERT OR IGNORE INTO settings (id) VALUES (1);
-- Seed the auth_settings row if it does not exist.
INSERT OR IGNORE INTO auth_settings (id) VALUES (1);
`
// now returns the current time formatted for SQLite storage.
+183
View File
@@ -0,0 +1,183 @@
package store
import (
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
// User represents an authenticated user stored in the database.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"-"`
Email string `json:"email"`
Role string `json:"role"` // admin, viewer
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AuthSettings holds the authentication configuration (single-row pattern).
type AuthSettings struct {
AuthMode string `json:"auth_mode"` // local, oidc
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"-"`
OIDCIssuerURL string `json:"oidc_issuer_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
}
// CreateUser inserts a new user record.
func (s *Store) CreateUser(u User) (User, error) {
u.ID = uuid.New().String()
u.CreatedAt = now()
u.UpdatedAt = u.CreatedAt
_, err := s.db.Exec(
`INSERT INTO users (id, username, password_hash, email, role, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
u.ID, u.Username, u.PasswordHash, u.Email, u.Role, u.CreatedAt, u.UpdatedAt,
)
if err != nil {
return User{}, fmt.Errorf("insert user: %w", err)
}
return u, nil
}
// GetUserByID returns a single user by its ID.
func (s *Store) GetUserByID(id string) (User, error) {
var u User
err := s.db.QueryRow(
`SELECT id, username, password_hash, email, role, created_at, updated_at
FROM users WHERE id = ?`, id,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, fmt.Errorf("user %s: %w", id, ErrNotFound)
}
if err != nil {
return User{}, fmt.Errorf("query user: %w", err)
}
return u, nil
}
// GetUserByUsername returns a single user by username.
func (s *Store) GetUserByUsername(username string) (User, error) {
var u User
err := s.db.QueryRow(
`SELECT id, username, password_hash, email, role, created_at, updated_at
FROM users WHERE username = ?`, username,
).Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt)
if errors.Is(err, sql.ErrNoRows) {
return User{}, fmt.Errorf("user %q: %w", username, ErrNotFound)
}
if err != nil {
return User{}, fmt.Errorf("query user by username: %w", err)
}
return u, nil
}
// GetAllUsers returns every user ordered by username.
func (s *Store) GetAllUsers() ([]User, error) {
rows, err := s.db.Query(
`SELECT id, username, password_hash, email, role, created_at, updated_at
FROM users ORDER BY username`,
)
if err != nil {
return nil, fmt.Errorf("query users: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
return users, rows.Err()
}
// UpdateUser updates a user's mutable fields (username, email, role).
func (s *Store) UpdateUser(u User) error {
u.UpdatedAt = now()
result, err := s.db.Exec(
`UPDATE users SET username=?, email=?, role=?, updated_at=? WHERE id=?`,
u.Username, u.Email, u.Role, u.UpdatedAt, u.ID,
)
if err != nil {
return fmt.Errorf("update user: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("user %s: %w", u.ID, ErrNotFound)
}
return nil
}
// UpdateUserPassword updates a user's password hash.
func (s *Store) UpdateUserPassword(id string, passwordHash string) error {
ts := now()
result, err := s.db.Exec(
`UPDATE users SET password_hash=?, updated_at=? WHERE id=?`,
passwordHash, ts, id,
)
if err != nil {
return fmt.Errorf("update user password: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("user %s: %w", id, ErrNotFound)
}
return nil
}
// DeleteUser removes a user by ID.
func (s *Store) DeleteUser(id string) error {
result, err := s.db.Exec(`DELETE FROM users WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete user: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("user %s: %w", id, ErrNotFound)
}
return nil
}
// UserCount returns the total number of users.
func (s *Store) UserCount() (int, error) {
var count int
err := s.db.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&count)
if err != nil {
return 0, fmt.Errorf("count users: %w", err)
}
return count, nil
}
// GetAuthSettings returns the auth settings (single-row pattern, always row id=1).
func (s *Store) GetAuthSettings() (AuthSettings, error) {
var as AuthSettings
err := s.db.QueryRow(
`SELECT auth_mode, oidc_client_id, oidc_client_secret, oidc_issuer_url, oidc_redirect_url
FROM auth_settings WHERE id = 1`,
).Scan(&as.AuthMode, &as.OIDCClientID, &as.OIDCClientSecret, &as.OIDCIssuerURL, &as.OIDCRedirectURL)
if err != nil {
return AuthSettings{}, fmt.Errorf("query auth settings: %w", err)
}
return as, nil
}
// UpdateAuthSettings updates the auth settings row.
func (s *Store) UpdateAuthSettings(as AuthSettings) error {
_, err := s.db.Exec(
`UPDATE auth_settings SET auth_mode=?, oidc_client_id=?, oidc_client_secret=?, oidc_issuer_url=?, oidc_redirect_url=?
WHERE id = 1`,
as.AuthMode, as.OIDCClientID, as.OIDCClientSecret, as.OIDCIssuerURL, as.OIDCRedirectURL,
)
if err != nil {
return fmt.Errorf("update auth settings: %w", err)
}
return nil
}