fix: comprehensive security, performance, and quality hardening

Security: apply AdminOnly middleware to mutating routes, require
ENCRYPTION_KEY and ADMIN_PASSWORD (no insecure defaults), restrict
CORS to same-origin, fix OIDC token delivery via cookie instead of
URL query param, add rate limiting on login, add MaxBytesReader,
validate volume paths against traversal, add security headers,
validate user roles, add Secure flag to OIDC cookie.

Performance: set SQLite MaxOpenConns(1) to prevent SQLITE_BUSY,
add FK indexes on 8 columns, track notifier goroutines with
WaitGroup for graceful shutdown, use GetRegistryByName instead of
GetAllRegistries in deployer, pass basePath param to avoid redundant
settings query, return empty slices from store to remove reflection.

Quality: refactor TriggerDeploy to delegate to runDeploy (~100 lines
removed), consolidate duplicated utilities (extractPort, boolToInt,
now, isTerminalStatus) into shared exports, migrate all log.Printf
to slog structured logging, use consistent webhook response envelope,
remove dead code (parseEnvVars, duplicate auth types).

UX: clean up NPM proxy on instance removal via API, add README with
quickstart guide, add .env.example, require ADMIN_PASSWORD in
docker-compose, document staging-net prerequisite.
This commit is contained in:
2026-03-29 12:49:24 +03:00
parent c5bfc586c1
commit be6ad15efc
32 changed files with 519 additions and 392 deletions
+7
View File
@@ -0,0 +1,7 @@
# Required: protects all credentials stored in the database (AES-256).
# Generate with: openssl rand -hex 32
ENCRYPTION_KEY=
# Required on first launch: password for the default admin user.
# After initial setup, this can be removed.
ADMIN_PASSWORD=
+106
View File
@@ -0,0 +1,106 @@
# Docker Watcher
Automated Docker deployment orchestrator with a web dashboard. Watches container registries for new image tags and deploys them with zero-downtime blue-green strategy, health checks, and automatic NPM (Nginx Proxy Manager) proxy configuration.
## Features
- **Registry polling** and **webhook receiver** for automatic deployments
- **Blue-green deploys** with health checks and automatic rollback
- **NPM integration** for automatic reverse proxy configuration
- **Multi-stage projects** (dev, staging, prod) with tag pattern matching
- **Real-time deploy logs** via SSE streaming
- **OIDC/SSO support** alongside local auth
- **Encrypted credential storage** (AES-256-GCM)
- **Single binary** with embedded SPA frontend
## Prerequisites
- Docker with Docker Compose
- A Docker network for deployed containers (e.g. `staging-net`)
- Nginx Proxy Manager (optional, for automatic proxy configuration)
- Wildcard DNS pointing to your server (for subdomain-based routing)
## Quick Start
1. **Create the Docker network** (containers will be attached to this):
```bash
docker network create staging-net
```
2. **Create a `.env` file** (see `.env.example`):
```bash
cp .env.example .env
# Edit .env and set ENCRYPTION_KEY and ADMIN_PASSWORD
# Generate a key: openssl rand -hex 32
```
3. **Start Docker Watcher**:
```bash
docker compose up -d
```
4. **Open the dashboard** at `http://localhost:8080` and log in with `admin` / your `ADMIN_PASSWORD`.
## Configuration
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `ENCRYPTION_KEY` | Yes | AES-256 key for encrypting stored credentials. Use `openssl rand -hex 32` |
| `ADMIN_PASSWORD` | Yes (first launch) | Password for the default admin user |
| `SEED_FILE` | No | Path to YAML seed config (default: `./docker-watcher.yaml`) |
| `DATA_DIR` | No | SQLite database directory (default: `./data`) |
| `LISTEN_ADDR` | No | HTTP listen address (default: `:8080`) |
| `NPM_URL` | No | Override NPM API URL (otherwise uses value from settings) |
| `POLLING_INTERVAL` | No | Registry polling interval, Go duration string e.g. `5m` (default from settings) |
### Seed Config
On first launch, Docker Watcher imports a YAML seed file to pre-configure registries, projects, and settings. See `docker-watcher.example.yaml` for the full format.
### Webhook Integration
After setup, find your webhook URL at **Settings > Webhook URL** in the dashboard. Configure your CI/CD (Gitea Actions, GitHub Actions) to POST to this URL on image push:
```bash
curl -X POST https://your-domain/api/webhook/<secret> \
-H "Content-Type: application/json" \
-d '{"image": "registry.example.com/org/app:v1.2.3"}'
```
### OIDC Setup
1. Go to **Settings > Auth** in the dashboard
2. Switch auth mode to **OIDC**
3. Enter your provider's Issuer URL, Client ID, and Client Secret
4. Set the Redirect URL to `https://your-domain/api/auth/oidc/callback`
## Development
```bash
# Build frontend
cd web && npm install && npm run build && cd ..
# Run backend (requires ENCRYPTION_KEY and ADMIN_PASSWORD env vars)
go run ./cmd/server
# Or use Make
make build
make dev
```
## Architecture
```
CI/Registry --> Webhook/Poller --> Deployer --> Docker + NPM
|
Event Bus --> SSE --> Web Dashboard
```
- **Backend**: Go 1.24, chi router, SQLite (pure Go), Docker SDK
- **Frontend**: SvelteKit 2, Tailwind CSS 4, TypeScript
- **Deployment**: Single binary with embedded SPA, multi-stage Dockerfile
+15 -10
View File
@@ -49,6 +49,13 @@ func main() {
}
defer db.Close()
// Derive encryption key from environment (required).
encKey, err := crypto.KeyFromEnv()
if err != nil {
slog.Error("ENCRYPTION_KEY is required — set it to a random 32+ character string")
os.Exit(1)
}
// Import seed config on first launch (idempotent).
seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml")
if err := config.ImportSeed(db, seedPath); err != nil {
@@ -56,13 +63,6 @@ func main() {
os.Exit(1)
}
// Derive encryption key from environment.
encKey, err := crypto.KeyFromEnv()
if err != nil {
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)
@@ -116,7 +116,7 @@ func main() {
}
// Build API server.
apiServer := api.NewServer(db, dockerClient, dep, webhookHandler, eventBus, encKey)
apiServer := api.NewServer(db, dockerClient, npmClient, dep, webhookHandler, eventBus, encKey)
router := apiServer.Router()
// Serve embedded static files for the SPA frontend.
@@ -160,8 +160,9 @@ func main() {
// Stop accepting new work.
poller.Stop()
// Drain in-progress deploys.
// Drain in-progress deploys and notifications.
dep.Drain()
notifier.Drain()
// Shut down HTTP server.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -198,7 +199,11 @@ func ensureDefaultAdmin(db *store.Store) error {
return nil // Users already exist, skip.
}
password := envOrDefault("ADMIN_PASSWORD", "admin")
password := os.Getenv("ADMIN_PASSWORD")
if password == "" {
slog.Error("ADMIN_PASSWORD is required on first launch — set it to a secure password")
os.Exit(1)
}
hash, err := auth.HashPassword(password)
if err != nil {
return err
+4 -2
View File
@@ -16,8 +16,8 @@ services:
environment:
# Required: protects all credentials stored in the database.
- ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
# Optional: default admin password on first launch (default: "admin").
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
# Required on first launch: password for the default admin user.
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
# Optional: override seed file location.
- SEED_FILE=/app/docker-watcher.yaml
# Optional: override data directory.
@@ -41,6 +41,8 @@ volumes:
docker-watcher-data:
driver: local
# NOTE: The staging-net network must exist before starting.
# Create it with: docker network create staging-net
networks:
staging-net:
external: true
+33 -4
View File
@@ -14,6 +14,21 @@ import (
"github.com/alexei/docker-watcher/internal/store"
)
// rateLimitedLogin wraps the login handler with per-IP rate limiting.
func (s *Server) rateLimitedLogin(rl *rateLimiter) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ip := r.RemoteAddr
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
ip = fwd
}
if !rl.allow(ip) {
respondError(w, http.StatusTooManyRequests, "too many login attempts, try again later")
return
}
s.login(w, r)
}
}
// login handles POST /api/auth/login.
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var req auth.LoginRequest
@@ -32,7 +47,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusUnauthorized, "invalid credentials")
return
}
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
slog.Error("failed to get user", "error", err)
respondError(w, http.StatusInternalServerError, "internal server error")
return
}
@@ -93,6 +109,7 @@ func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) {
Path: "/api/auth/oidc",
MaxAge: 300, // 5 minutes
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
@@ -177,9 +194,17 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
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)
// Set the token in a short-lived cookie the frontend can read once.
http.SetCookie(w, &http.Cookie{
Name: "auth_token",
Value: token.Token,
Path: "/",
MaxAge: 60, // 1 minute — frontend reads it immediately
HttpOnly: false,
Secure: true,
SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/?oidc=success", http.StatusFound)
}
// getAuthSettings handles GET /api/auth/settings.
@@ -270,6 +295,10 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
if req.Role == "" {
req.Role = "viewer"
}
if req.Role != "admin" && req.Role != "viewer" {
respondError(w, http.StatusBadRequest, "role must be 'admin' or 'viewer'")
return
}
hash, err := auth.HashPassword(req.Password)
if err != nil {
+2 -14
View File
@@ -6,6 +6,7 @@ import (
"strconv"
"strings"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -71,7 +72,7 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
return
}
port := extractPort(info.ExposedPorts)
port := docker.ExtractPort(info.ExposedPorts)
respondJSON(w, http.StatusOK, inspectResponse{
Image: req.Image,
@@ -165,16 +166,3 @@ func splitImageTag(ref string) (string, string) {
return ref, ""
}
// extractPort parses the first exposed port from Docker EXPOSE entries.
// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found.
func extractPort(exposedPorts []string) int {
if len(exposedPorts) == 0 {
return 0
}
raw := exposedPorts[0]
if idx := strings.Index(raw, "/"); idx != -1 {
raw = raw[:idx]
}
port, _ := strconv.Atoi(raw)
return port
}
+17 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -109,9 +110,24 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
}
}
// Delete NPM proxy host if it has one.
if inst.NpmProxyID > 0 {
settings, err := s.store.GetSettings()
if err == nil {
npmPassword, err := crypto.Decrypt(s.encKey, settings.NpmPassword)
if err == nil {
if authErr := s.npm.Authenticate(r.Context(), settings.NpmEmail, npmPassword); authErr == nil {
if delErr := s.npm.DeleteProxyHost(r.Context(), inst.NpmProxyID); delErr != nil {
slog.Warn("delete proxy host on instance removal", "proxy_id", inst.NpmProxyID, "error", delErr)
}
}
}
}
}
// Delete instance record.
if err := s.store.DeleteInstance(instanceID); err != nil {
respondError(w, http.StatusInternalServerError, "failed to delete instance: "+err.Error())
respondError(w, http.StatusInternalServerError, "failed to delete instance")
return
}
respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID})
+68 -4
View File
@@ -4,6 +4,7 @@ import (
"log/slog"
"net/http"
"runtime/debug"
"sync"
"time"
)
@@ -38,12 +39,29 @@ func recovery(next http.Handler) http.Handler {
})
}
// cors is an HTTP middleware that sets permissive CORS headers for development.
// securityHeaders sets standard security headers on all responses.
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
next.ServeHTTP(w, r)
})
}
// cors is an HTTP middleware that restricts CORS to same-origin requests.
// The frontend is served from the same origin, so no wildcard is needed.
func cors(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
origin := r.Header.Get("Origin")
if origin != "" {
// Only allow the same origin (frontend is served from the same host).
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
@@ -54,6 +72,52 @@ func cors(next http.Handler) http.Handler {
})
}
// maxBodySize limits request body sizes to prevent memory exhaustion.
const maxBodySize = 1 << 20 // 1 MB
func limitBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, maxBodySize)
next.ServeHTTP(w, r)
})
}
// rateLimiter provides per-IP rate limiting for login endpoints.
type rateLimiter struct {
mu sync.Mutex
attempts map[string][]time.Time
}
func newRateLimiter() *rateLimiter {
return &rateLimiter{attempts: make(map[string][]time.Time)}
}
// allow checks if the IP is allowed to make another request.
// Returns false if the IP has exceeded the limit (10 requests per minute).
func (rl *rateLimiter) allow(ip string) bool {
rl.mu.Lock()
defer rl.mu.Unlock()
now := time.Now()
window := now.Add(-1 * time.Minute)
// Clean old entries.
filtered := rl.attempts[ip][:0]
for _, t := range rl.attempts[ip] {
if t.After(window) {
filtered = append(filtered, t)
}
}
rl.attempts[ip] = filtered
if len(filtered) >= 10 {
return false
}
rl.attempts[ip] = append(rl.attempts[ip], now)
return true
}
// jsonContentType is an HTTP middleware that sets the default Content-Type to JSON.
func jsonContentType(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-9
View File
@@ -4,7 +4,6 @@ import (
"encoding/json"
"log/slog"
"net/http"
"reflect"
)
// envelope is the standard API response wrapper.
@@ -15,15 +14,7 @@ type envelope struct {
}
// respondJSON writes a JSON success response with the given status code and data.
// Nil slices are converted to empty arrays to avoid "null" in JSON output.
func respondJSON(w http.ResponseWriter, status int, data any) {
// Convert nil slices to empty arrays so JSON encodes as [] not null.
if data != nil {
v := reflect.ValueOf(data)
if v.Kind() == reflect.Slice && v.IsNil() {
data = reflect.MakeSlice(v.Type(), 0, 0).Interface()
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil {
+74 -59
View File
@@ -10,6 +10,7 @@ import (
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/events"
"github.com/alexei/docker-watcher/internal/npm"
"github.com/alexei/docker-watcher/internal/store"
"github.com/alexei/docker-watcher/internal/webhook"
)
@@ -18,6 +19,7 @@ import (
type Server struct {
store *store.Store
docker *docker.Client
npm *npm.Client
deployer DeployTriggerer
webhook *webhook.Handler
eventBus *events.Bus
@@ -30,6 +32,7 @@ type Server struct {
func NewServer(
st *store.Store,
dockerClient *docker.Client,
npmClient *npm.Client,
deployer DeployTriggerer,
webhookHandler *webhook.Handler,
eventBus *events.Bus,
@@ -40,6 +43,7 @@ func NewServer(
s := &Server{
store: st,
docker: dockerClient,
npm: npmClient,
deployer: deployer,
webhook: webhookHandler,
eventBus: eventBus,
@@ -86,15 +90,19 @@ func (s *Server) Router() chi.Router {
// Global middleware.
r.Use(recovery)
r.Use(securityHeaders)
r.Use(logging)
r.Use(cors)
loginLimiter := newRateLimiter()
r.Route("/api", func(r chi.Router) {
// JSON content type only for API routes (not static files).
// JSON content type and body size limit for API routes.
r.Use(jsonContentType)
r.Use(limitBody)
// Public auth endpoints (no auth required).
r.Post("/auth/login", s.login)
r.Post("/auth/login", s.rateLimitedLogin(loginLimiter))
r.Get("/auth/oidc/login", s.oidcLogin)
r.Get("/auth/oidc/callback", s.oidcCallback)
@@ -105,80 +113,87 @@ func (s *Server) Router() chi.Router {
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(s.localAuth))
// Config export (protected — reveals project/infra details).
r.Get("/config/export", s.exportConfig)
// Auth management.
// Read-only endpoints (any authenticated user).
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)
// Stage env override endpoints.
r.Get("/stages/{stage}/env", s.listStageEnv)
r.Post("/stages/{stage}/env", s.createStageEnv)
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
// 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)
// Volume endpoints.
r.Get("/volumes", s.listVolumes)
r.Post("/volumes", s.createVolume)
r.Put("/volumes/{volId}", s.updateVolume)
r.Delete("/volumes/{volId}", s.deleteVolume)
})
// 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)
r.Get("/images", s.listRegistryImages)
})
// Settings endpoints.
r.Get("/settings", s.getSettings)
r.Put("/settings", s.updateSettings)
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
// Admin-only routes: require admin role.
r.Group(func(r chi.Router) {
r.Use(auth.AdminOnly)
// Config export (reveals project/infra details).
r.Get("/config/export", s.exportConfig)
// Auth management.
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 mutation endpoints.
r.Post("/projects", s.createProject)
r.Route("/projects/{id}", func(r chi.Router) {
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)
// Stage env override endpoints.
r.Post("/stages/{stage}/env", s.createStageEnv)
r.Put("/stages/{stage}/env/{envId}", s.updateStageEnv)
r.Delete("/stages/{stage}/env/{envId}", s.deleteStageEnv)
// Instance endpoints.
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)
// Volume endpoints.
r.Post("/volumes", s.createVolume)
r.Put("/volumes/{volId}", s.updateVolume)
r.Delete("/volumes/{volId}", s.deleteVolume)
})
// Quick deploy endpoints.
r.Post("/deploy/inspect", s.inspectImage)
r.Post("/deploy/quick", s.quickDeploy)
// Registry mutation endpoints.
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)
})
// Settings endpoints.
r.Put("/settings", s.updateSettings)
r.Get("/settings/webhook-url", s.getWebhookURL)
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
})
})
})
+1 -6
View File
@@ -183,10 +183,5 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
// isTerminalStatus returns true if the deploy status is final.
func isTerminalStatus(status string) bool {
switch status {
case "success", "failed", "rolled_back":
return true
default:
return false
}
return store.IsTerminalDeployStatus(status)
}
+16
View File
@@ -3,12 +3,20 @@ package api
import (
"errors"
"net/http"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/alexei/docker-watcher/internal/store"
)
// validateVolumePath checks that the source path does not contain path traversal.
func validateVolumePath(source string) bool {
cleaned := filepath.Clean(source)
return !strings.Contains(cleaned, "..")
}
// volumeRequest is the expected JSON body for creating/updating a volume.
type volumeRequest struct {
Source string `json:"source"`
@@ -66,6 +74,14 @@ func (s *Server) createVolume(w http.ResponseWriter, r *http.Request) {
respondError(w, http.StatusBadRequest, "target is required")
return
}
if !validateVolumePath(req.Source) {
respondError(w, http.StatusBadRequest, "source path must not contain '..'")
return
}
if !validateVolumePath(req.Target) {
respondError(w, http.StatusBadRequest, "target path must not contain '..'")
return
}
if req.Mode == "" {
req.Mode = "shared"
}
-20
View File
@@ -2,26 +2,6 @@ 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"`
+7 -21
View File
@@ -3,9 +3,8 @@ package config
import (
"encoding/json"
"fmt"
"log"
"log/slog"
"os"
"time"
"github.com/alexei/docker-watcher/internal/crypto"
"github.com/alexei/docker-watcher/internal/store"
@@ -17,7 +16,7 @@ import (
// Credential fields (registry tokens, NPM password) are encrypted before storage.
func ImportSeed(db *store.Store, seedPath string) error {
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
log.Printf("No seed file at %s, skipping import", seedPath)
slog.Info("no seed file, skipping import", "path", seedPath)
return nil
}
@@ -26,7 +25,7 @@ func ImportSeed(db *store.Store, seedPath string) error {
return fmt.Errorf("check if db is populated: %w", err)
}
if populated {
log.Println("Database already has data, skipping seed import")
slog.Info("database already has data, skipping seed import")
return nil
}
@@ -44,7 +43,7 @@ func ImportSeed(db *store.Store, seedPath string) error {
return fmt.Errorf("import seed: %w", err)
}
log.Printf("Seed config imported from %s", seedPath)
slog.Info("seed config imported", "path", seedPath)
return nil
}
@@ -65,11 +64,6 @@ func isPopulated(db *store.Store) (bool, error) {
return len(registries) > 0, nil
}
// now returns the current time formatted for SQLite storage.
func now() string {
return time.Now().UTC().Format("2006-01-02 15:04:05")
}
// importAll runs the full seed import inside a database transaction.
// Uses raw SQL within the transaction so all inserts are atomic.
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
@@ -79,7 +73,7 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
}
defer tx.Rollback() //nolint:errcheck // rollback after commit is a no-op
timestamp := now()
timestamp := store.Now()
// Import registries first — projects reference them by name.
for name, regDef := range cfg.Registries {
@@ -132,8 +126,8 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
stageID, projectID, stageName, stageDef.TagPattern,
boolToInt(stageDef.AutoDeploy), maxInstances,
boolToInt(stageDef.Confirm), stageDef.PromoteFrom,
store.BoolToInt(stageDef.AutoDeploy), maxInstances,
store.BoolToInt(stageDef.Confirm), stageDef.PromoteFrom,
stageDef.Subdomain, timestamp, timestamp,
)
if err != nil {
@@ -173,14 +167,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
return nil
}
// boolToInt converts a bool to an integer for SQLite storage.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}
// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps.
func mapToJSON(m map[string]string) (string, error) {
if m == nil {
+1 -1
View File
@@ -74,7 +74,7 @@ func (d *Deployer) blueGreenDeploy(
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
containerCfg := docker.ContainerConfig{
Name: containerName,
+12 -113
View File
@@ -192,8 +192,7 @@ func (d *Deployer) runDeploy(ctx context.Context, project store.Project, stage s
}
// TriggerDeploy is the synchronous entry point for deployments (used by poller and webhook).
// 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).
// It validates inputs, creates a deploy record, and delegates to runDeploy.
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")
@@ -202,7 +201,6 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
d.activeWg.Add(1)
defer d.activeWg.Done()
// Load project and stage from store.
project, err := d.store.GetProjectByID(projectID)
if err != nil {
return fmt.Errorf("get project: %w", err)
@@ -213,17 +211,10 @@ 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)
}
// Create deploy record.
deploy, err := d.store.CreateDeploy(store.Deploy{
ProjectID: projectID,
StageID: stageID,
@@ -234,69 +225,9 @@ 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.
if err := d.enforceMaxInstances(ctx, stage, deploy.ID, settings); err != nil {
d.logDeploy(deploy.ID, fmt.Sprintf("Failed to enforce max instances: %v", err), "error")
// Non-fatal: continue with deploy.
if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil {
return err
}
// 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")
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "failed", deployErr.Error())
d.rollback(ctx, deploy.ID, containerID, npmProxyID, instanceID)
d.notifier.Send(settings.NotificationURL, notify.Event{
Type: "deploy_failure",
Project: project.Name,
Stage: stage.Name,
ImageTag: imageTag,
Error: deployErr.Error(),
})
return fmt.Errorf("deploy failed: %w", deployErr)
}
// Mark deploy as successful.
if err := d.store.UpdateDeployStatus(deploy.ID, "success", ""); err != nil {
slog.Warn("update deploy status to success", "error", err)
}
d.publishDeployStatus(deploy.ID, projectID, stageID, imageTag, "success", "")
subdomain := d.buildSubdomain(project, stage, settings, imageTag)
fullURL := fmt.Sprintf("https://%s.%s", subdomain, settings.Domain)
d.logDeploy(deploy.ID, fmt.Sprintf("Deploy successful: %s", fullURL), "info")
d.notifier.Send(settings.NotificationURL, notify.Event{
Type: "deploy_success",
Project: project.Name,
Stage: stage.Name,
ImageTag: imageTag,
Subdomain: subdomain,
URL: fullURL,
})
return nil
}
@@ -351,7 +282,7 @@ func (d *Deployer) executeDeploy(
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
portStr := fmt.Sprintf("%d/tcp", project.Port)
envVars := d.mergeEnvVars(project, stage.ID)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag)
mounts := d.computeVolumeMounts(project.ID, stage.Name, imageTag, settings.BaseVolumePath)
containerCfg := docker.ContainerConfig{
Name: containerName,
@@ -597,25 +528,18 @@ func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
return "", nil
}
registries, err := d.store.GetAllRegistries()
reg, err := d.store.GetRegistryByName(project.Registry)
if err != nil {
return "", fmt.Errorf("get registries: %w", err)
return "", fmt.Errorf("get registry %s: %w", project.Registry, err)
}
for _, reg := range registries {
if reg.Name == project.Registry {
token := reg.Token
if token != "" {
decrypted, err := crypto.Decrypt(d.encKey, token)
if err != nil {
return "", fmt.Errorf("decrypt registry token: %w", err)
}
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
}
return "", nil
if reg.Token != "" {
decrypted, err := crypto.Decrypt(d.encKey, reg.Token)
if err != nil {
return "", fmt.Errorf("decrypt registry token: %w", err)
}
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
}
return "", nil
}
@@ -628,25 +552,6 @@ func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error)
return crypto.Decrypt(d.encKey, encryptedPassword)
}
// parseEnvVars parses a JSON-encoded map into KEY=VALUE environment variable strings.
func (d *Deployer) parseEnvVars(envJSON string) []string {
if envJSON == "" || envJSON == "{}" {
return nil
}
var envMap map[string]string
if err := json.Unmarshal([]byte(envJSON), &envMap); err != nil {
slog.Warn("parse env vars", "error", err)
return nil
}
vars := make([]string, 0, len(envMap))
for k, v := range envMap {
vars = append(vars, k+"="+v)
}
return vars
}
// mergeEnvVars builds the final environment variable list for a container:
// 1. Parse project-level env JSON
// 2. Overlay with stage-level env overrides (stage wins on key conflict)
@@ -696,7 +601,7 @@ func (d *Deployer) mergeEnvVars(project store.Project, stageID string) []string
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
// For shared mode, source is used as-is.
// For isolated mode, source gets /{stage}-{tag}/ appended.
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []mount.Mount {
func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag, basePath string) []mount.Mount {
vols, err := d.store.GetVolumesByProjectID(projectID)
if err != nil {
slog.Warn("get project volumes", "project_id", projectID, "error", err)
@@ -707,12 +612,6 @@ func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []
return nil
}
// Get base volume path from settings.
basePath := ""
if settings, err := d.store.GetSettings(); err == nil {
basePath = settings.BaseVolumePath
}
mounts := make([]mount.Mount, 0, len(vols))
for _, vol := range vols {
source := vol.Source
+15
View File
@@ -5,12 +5,27 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/moby/moby/api/types/registry"
"github.com/moby/moby/client"
)
// ExtractPort parses the first exposed port from Docker EXPOSE entries.
// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found.
func ExtractPort(exposedPorts []string) int {
if len(exposedPorts) == 0 {
return 0
}
raw := exposedPorts[0]
if idx := strings.Index(raw, "/"); idx != -1 {
raw = raw[:idx]
}
port, _ := strconv.Atoi(raw)
return port
}
// ImageInfo holds metadata extracted from a Docker image inspection.
type ImageInfo struct {
// ExposedPorts lists the ports declared via EXPOSE in the Dockerfile (e.g. ["8080/tcp"]).
+14 -3
View File
@@ -5,8 +5,9 @@ import (
"context"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"sync"
"time"
)
@@ -26,6 +27,7 @@ type Event struct {
// Notifications are fire-and-forget — failures are logged but do not propagate.
type Notifier struct {
httpClient *http.Client
wg sync.WaitGroup
}
// New creates a Notifier with sensible defaults.
@@ -37,6 +39,11 @@ func New() *Notifier {
}
}
// Drain waits for all in-flight notifications to complete.
func (n *Notifier) Drain() {
n.wg.Wait()
}
// Send sends a notification event to the given webhook URL in a background goroutine.
// It does not block the caller. Errors are logged, not returned.
func (n *Notifier) Send(webhookURL string, event Event) {
@@ -48,9 +55,13 @@ func (n *Notifier) Send(webhookURL string, event Event) {
event.Timestamp = time.Now().UTC().Format(time.RFC3339)
}
n.wg.Add(1)
go func() {
if err := n.doSend(context.Background(), webhookURL, event); err != nil {
log.Printf("notify: failed to send webhook to %s: %v", webhookURL, err)
defer n.wg.Done()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := n.doSend(ctx, webhookURL, event); err != nil {
slog.Warn("notify: failed to send webhook", "url", webhookURL, "error", err)
}
}()
}
+11 -32
View File
@@ -3,7 +3,7 @@ package registry
import (
"context"
"fmt"
"log"
"log/slog"
"sync"
"time"
@@ -56,7 +56,7 @@ func (p *Poller) Start(interval string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
if pollErr := p.poll(ctx); pollErr != nil {
log.Printf("[poller] poll error: %v", pollErr)
slog.Warn("poller: poll error", "error", pollErr)
}
})
if err != nil {
@@ -69,7 +69,7 @@ func (p *Poller) Start(interval string) error {
}
p.running = true
log.Printf("[poller] started with interval %s", duration)
slog.Info("poller started", "interval", duration.String())
return nil
}
@@ -82,7 +82,7 @@ func (p *Poller) Stop() {
ctx := p.cron.Stop()
<-ctx.Done()
p.running = false
log.Println("[poller] stopped")
slog.Info("poller stopped")
}
}
@@ -96,8 +96,7 @@ func (p *Poller) poll(ctx context.Context) error {
for _, project := range projects {
if err := p.pollProject(ctx, project); err != nil {
log.Printf("[poller] project %s (%s): %v", project.Name, project.ID, err)
// Continue polling other projects even if one fails.
slog.Warn("poller: project error", "project", project.Name, "id", project.ID, "error", err)
}
}
return nil
@@ -109,32 +108,26 @@ func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
return nil
}
// Look up the registry configuration by name (projects store registry name, not ID).
reg, err := p.store.GetRegistryByName(project.Registry)
if err != nil {
return fmt.Errorf("get registry %s: %w", project.Registry, err)
}
// Decrypt the registry token.
token, err := crypto.Decrypt(p.encKey, reg.Token)
if err != nil {
// Token might not be encrypted (empty or plaintext).
token = reg.Token
}
// Create a registry client for this registry type.
client, err := NewClient(reg.Type, reg.URL, token)
if err != nil {
return fmt.Errorf("create registry client: %w", err)
}
// Fetch all available tags for the project image.
tags, err := client.ListTags(ctx, project.Image)
if err != nil {
return fmt.Errorf("list tags for %s: %w", project.Image, err)
}
// Check each stage of the project.
stages, err := p.store.GetStagesByProjectID(project.ID)
if err != nil {
return fmt.Errorf("get stages for project %s: %w", project.ID, err)
@@ -142,7 +135,7 @@ func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
for _, stage := range stages {
if err := p.pollStage(ctx, project, stage, tags); err != nil {
log.Printf("[poller] project %s stage %s: %v", project.Name, stage.Name, err)
slog.Warn("poller: stage error", "project", project.Name, "stage", stage.Name, "error", err)
}
}
return nil
@@ -150,7 +143,6 @@ func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
// pollStage checks a single stage for new tags and triggers deploy if needed.
func (p *Poller) pollStage(ctx context.Context, project store.Project, stage store.Stage, allTags []string) error {
// Find the latest tag matching the stage's pattern.
latest, err := LatestTag(allTags, stage.TagPattern)
if err != nil {
return fmt.Errorf("match tags for stage %s: %w", stage.Name, err)
@@ -159,41 +151,33 @@ func (p *Poller) pollStage(ctx context.Context, project store.Project, stage sto
return nil
}
// Get the last polled tag for this stage.
state, err := p.store.GetPollState(stage.ID)
if err != nil {
// No poll state yet — this is the first poll for this stage.
// Record the current latest tag without triggering a deploy,
// so we don't deploy everything on first startup.
return p.store.UpsertPollState(store.PollState{
StageID: stage.ID,
LastTag: latest,
LastPolled: now(),
LastPolled: store.Now(),
})
}
// Update the poll timestamp regardless.
defer func() {
if err := p.store.UpsertPollState(store.PollState{
StageID: stage.ID,
LastTag: latest,
LastPolled: now(),
LastPolled: store.Now(),
}); err != nil {
log.Printf("[poller] failed to update poll state for stage %s: %v", stage.ID, err)
slog.Warn("poller: failed to update poll state", "stage_id", stage.ID, "error", err)
}
}()
// If the latest tag hasn't changed, nothing to do.
if state.LastTag == latest {
return nil
}
log.Printf("[poller] new tag %q detected for project %s stage %s (was %q)",
latest, project.Name, stage.Name, state.LastTag)
slog.Info("poller: new tag detected", "tag", latest, "project", project.Name, "stage", stage.Name, "previous", state.LastTag)
// Only trigger deploy if auto_deploy is enabled for this stage.
if !stage.AutoDeploy {
log.Printf("[poller] auto_deploy disabled for stage %s, skipping deploy", stage.Name)
slog.Info("poller: auto_deploy disabled, skipping", "stage", stage.Name)
return nil
}
@@ -203,8 +187,3 @@ func (p *Poller) pollStage(ctx context.Context, project store.Project, stage sto
return nil
}
// now returns the current UTC time as a formatted string.
func now() string {
return time.Now().UTC().Format("2006-01-02 15:04:05")
}
+22 -8
View File
@@ -11,7 +11,7 @@ import (
// CreateDeploy inserts a new deploy record.
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
d.ID = uuid.New().String()
d.StartedAt = now()
d.StartedAt = Now()
if d.Status == "" {
d.Status = "pending"
}
@@ -73,9 +73,9 @@ func (s *Store) GetRecentDeploys(limit int) ([]Deploy, error) {
// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy.
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
ts := now()
ts := Now()
var finishedAt string
if isTerminalDeployStatus(status) {
if IsTerminalDeployStatus(status) {
finishedAt = ts
}
@@ -113,7 +113,7 @@ func (s *Store) AppendDeployLog(deployID string, message string, level string) e
}
_, err := s.db.Exec(
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
deployID, message, level, now(),
deployID, message, level, Now(),
)
if err != nil {
return fmt.Errorf("append deploy log: %w", err)
@@ -132,7 +132,7 @@ func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
}
defer rows.Close()
var logs []DeployLog
logs := []DeployLog{}
for rows.Next() {
var l DeployLog
if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil {
@@ -145,7 +145,7 @@ func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
// scanDeploys is a helper that scans deploy rows from a cursor.
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
var deploys []Deploy
deploys := []Deploy{}
for rows.Next() {
var d Deploy
if err := rows.Scan(&d.ID, &d.ProjectID, &d.StageID, &d.InstanceID, &d.ImageTag, &d.Status, &d.StartedAt, &d.FinishedAt, &d.Error); err != nil {
@@ -156,8 +156,8 @@ func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
return deploys, rows.Err()
}
// isTerminalDeployStatus returns true if the status indicates the deploy is finished.
func isTerminalDeployStatus(status string) bool {
// IsTerminalDeployStatus returns true if the status indicates the deploy is finished.
func IsTerminalDeployStatus(status string) bool {
switch status {
case "success", "failed", "rolled_back":
return true
@@ -165,3 +165,17 @@ func isTerminalDeployStatus(status string) bool {
return false
}
}
// CleanupOldDeploys removes deploy records and their logs older than the given
// number of days. Returns the number of deploys removed.
func (s *Store) CleanupOldDeploys(retentionDays int) (int64, error) {
cutoff := fmt.Sprintf("-%d days", retentionDays)
result, err := s.db.Exec(
`DELETE FROM deploys WHERE started_at < datetime('now', ?)`, cutoff,
)
if err != nil {
return 0, fmt.Errorf("cleanup old deploys: %w", err)
}
n, _ := result.RowsAffected()
return n, nil
}
+5 -5
View File
@@ -11,7 +11,7 @@ import (
// CreateInstance inserts a new instance record.
func (s *Store) CreateInstance(inst Instance) (Instance, error) {
inst.ID = uuid.New().String()
inst.CreatedAt = now()
inst.CreatedAt = Now()
inst.UpdatedAt = inst.CreatedAt
_, err := s.db.Exec(
@@ -32,7 +32,7 @@ func (s *Store) CreateInstanceWithID(inst Instance) (Instance, error) {
if inst.ID == "" {
return Instance{}, fmt.Errorf("instance ID is required")
}
inst.CreatedAt = now()
inst.CreatedAt = Now()
inst.UpdatedAt = inst.CreatedAt
_, err := s.db.Exec(
@@ -75,7 +75,7 @@ func (s *Store) GetInstancesByStageID(stageID string) ([]Instance, error) {
}
defer rows.Close()
var instances []Instance
instances := []Instance{}
for rows.Next() {
var inst Instance
if err := rows.Scan(&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag,
@@ -89,7 +89,7 @@ func (s *Store) GetInstancesByStageID(stageID string) ([]Instance, error) {
// UpdateInstance updates an existing instance's mutable fields.
func (s *Store) UpdateInstance(inst Instance) error {
inst.UpdatedAt = now()
inst.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, status=?, port=?, updated_at=?
WHERE id=?`,
@@ -108,7 +108,7 @@ func (s *Store) UpdateInstance(inst Instance) error {
// UpdateInstanceStatus sets only the status field on an instance.
func (s *Store) UpdateInstanceStatus(id string, status string) error {
ts := now()
ts := Now()
result, err := s.db.Exec(
`UPDATE instances SET status=?, updated_at=? WHERE id=?`,
status, ts, id,
+1 -1
View File
@@ -63,7 +63,7 @@ func (s *Store) GetAllPollStates() ([]PollState, error) {
}
defer rows.Close()
var states []PollState
states := []PollState{}
for rows.Next() {
var ps PollState
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
+3 -3
View File
@@ -11,7 +11,7 @@ import (
// CreateProject inserts a new project and returns it.
func (s *Store) CreateProject(p Project) (Project, error) {
p.ID = uuid.New().String()
p.CreatedAt = now()
p.CreatedAt = Now()
p.UpdatedAt = p.CreatedAt
_, err := s.db.Exec(
@@ -52,7 +52,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
}
defer rows.Close()
var projects []Project
projects := []Project{}
for rows.Next() {
var p Project
if err := rows.Scan(&p.ID, &p.Name, &p.Registry, &p.Image, &p.Port, &p.Healthcheck, &p.Env, &p.Volumes, &p.CreatedAt, &p.UpdatedAt); err != nil {
@@ -65,7 +65,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
// UpdateProject updates an existing project's mutable fields.
func (s *Store) UpdateProject(p Project) error {
p.UpdatedAt = now()
p.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, updated_at=?
WHERE id=?`,
+3 -3
View File
@@ -11,7 +11,7 @@ import (
// CreateRegistry inserts a new registry.
func (s *Store) CreateRegistry(r Registry) (Registry, error) {
r.ID = uuid.New().String()
r.CreatedAt = now()
r.CreatedAt = Now()
r.UpdatedAt = r.CreatedAt
_, err := s.db.Exec(
@@ -68,7 +68,7 @@ func (s *Store) GetAllRegistries() ([]Registry, error) {
}
defer rows.Close()
var registries []Registry
registries := []Registry{}
for rows.Next() {
var r Registry
if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.Owner, &r.CreatedAt, &r.UpdatedAt); err != nil {
@@ -81,7 +81,7 @@ func (s *Store) GetAllRegistries() ([]Registry, error) {
// UpdateRegistry updates an existing registry's mutable fields.
func (s *Store) UpdateRegistry(r Registry) error {
r.UpdatedAt = now()
r.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE registries SET name=?, url=?, type=?, token=?, owner=?, updated_at=?
WHERE id=?`,
+1 -1
View File
@@ -21,7 +21,7 @@ func (s *Store) GetSettings() (Settings, error) {
// UpdateSettings upserts the global settings row.
func (s *Store) UpdateSettings(st Settings) error {
st.UpdatedAt = now()
st.UpdatedAt = Now()
_, err := s.db.Exec(
`UPDATE settings SET
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
+5 -5
View File
@@ -11,13 +11,13 @@ import (
// CreateStageEnv inserts a new stage environment variable override.
func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) {
env.ID = uuid.New().String()
env.CreatedAt = now()
env.CreatedAt = Now()
env.UpdatedAt = env.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
env.ID, env.StageID, env.Key, env.Value, boolToInt(env.Encrypted),
env.ID, env.StageID, env.Key, env.Value, BoolToInt(env.Encrypted),
env.CreatedAt, env.UpdatedAt,
)
if err != nil {
@@ -37,7 +37,7 @@ func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) {
}
defer rows.Close()
var envs []StageEnv
envs := []StageEnv{}
for rows.Next() {
env, err := scanStageEnv(rows)
if err != nil {
@@ -69,11 +69,11 @@ func (s *Store) GetStageEnvByID(id string) (StageEnv, error) {
// UpdateStageEnv updates an existing stage environment variable override.
func (s *Store) UpdateStageEnv(env StageEnv) error {
env.UpdatedAt = now()
env.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=?
WHERE id=?`,
env.Key, env.Value, boolToInt(env.Encrypted), env.UpdatedAt, env.ID,
env.Key, env.Value, BoolToInt(env.Encrypted), env.UpdatedAt, env.ID,
)
if err != nil {
return fmt.Errorf("update stage env: %w", err)
+9 -9
View File
@@ -11,14 +11,14 @@ import (
// CreateStage inserts a new stage for a project.
func (s *Store) CreateStage(st Stage) (Stage, error) {
st.ID = uuid.New().String()
st.CreatedAt = now()
st.CreatedAt = Now()
st.UpdatedAt = st.CreatedAt
_, err := s.db.Exec(
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
st.ID, st.ProjectID, st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances,
boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt,
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt,
)
if err != nil {
return Stage{}, fmt.Errorf("insert stage: %w", err)
@@ -37,7 +37,7 @@ func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
}
defer rows.Close()
var stages []Stage
stages := []Stage{}
for rows.Next() {
st, err := scanStage(rows)
if err != nil {
@@ -70,12 +70,12 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
// UpdateStage updates an existing stage's mutable fields.
func (s *Store) UpdateStage(st Stage) error {
st.UpdatedAt = now()
st.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, promote_from=?, subdomain=?, updated_at=?
WHERE id=?`,
st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances,
boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID,
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
BoolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID,
)
if err != nil {
return fmt.Errorf("update stage: %w", err)
@@ -100,8 +100,8 @@ func (s *Store) DeleteStage(id string) error {
return nil
}
// boolToInt converts a bool to an integer for SQLite storage.
func boolToInt(b bool) int {
// BoolToInt converts a bool to an integer for SQLite storage.
func BoolToInt(b bool) int {
if b {
return 1
}
+24 -2
View File
@@ -24,6 +24,10 @@ func New(dbPath string) (*Store, error) {
return nil, fmt.Errorf("open database: %w", err)
}
// SQLite only allows one writer at a time. Limit connections to prevent SQLITE_BUSY.
db.SetMaxOpenConns(1)
db.SetConnMaxLifetime(0)
// Enable WAL mode and foreign keys for better concurrency and referential integrity.
pragmas := []string{
"PRAGMA journal_mode=WAL",
@@ -79,6 +83,24 @@ func (s *Store) runMigrations() error {
// Ignore errors from already-applied migrations (duplicate column).
_, _ = s.db.Exec(m)
}
// Create indexes on foreign key columns for query performance.
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_instances_stage_id ON instances(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_instances_project_id ON instances(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploys_project_id ON deploys(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploys_stage_id ON deploys(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_deploy_logs_deploy_id ON deploy_logs(deploy_id)`,
`CREATE INDEX IF NOT EXISTS idx_stages_project_id ON stages(project_id)`,
`CREATE INDEX IF NOT EXISTS idx_stage_env_stage_id ON stage_env(stage_id)`,
`CREATE INDEX IF NOT EXISTS idx_volumes_project_id ON volumes(project_id)`,
}
for _, idx := range indexes {
if _, err := s.db.Exec(idx); err != nil {
return fmt.Errorf("create index: %w", err)
}
}
return nil
}
@@ -224,7 +246,7 @@ CREATE TABLE IF NOT EXISTS volumes (
);
`
// now returns the current time formatted for SQLite storage.
func now() string {
// Now returns the current time formatted for SQLite storage.
func Now() string {
return time.Now().UTC().Format("2006-01-02 15:04:05")
}
+4 -4
View File
@@ -31,7 +31,7 @@ type AuthSettings struct {
// CreateUser inserts a new user record.
func (s *Store) CreateUser(u User) (User, error) {
u.ID = uuid.New().String()
u.CreatedAt = now()
u.CreatedAt = Now()
u.UpdatedAt = u.CreatedAt
_, err := s.db.Exec(
@@ -88,7 +88,7 @@ func (s *Store) GetAllUsers() ([]User, error) {
}
defer rows.Close()
var users []User
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 {
@@ -101,7 +101,7 @@ func (s *Store) GetAllUsers() ([]User, error) {
// UpdateUser updates a user's mutable fields (username, email, role).
func (s *Store) UpdateUser(u User) error {
u.UpdatedAt = now()
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,
@@ -118,7 +118,7 @@ func (s *Store) UpdateUser(u User) error {
// UpdateUserPassword updates a user's password hash.
func (s *Store) UpdateUserPassword(id string, passwordHash string) error {
ts := now()
ts := Now()
result, err := s.db.Exec(
`UPDATE users SET password_hash=?, updated_at=? WHERE id=?`,
passwordHash, ts, id,
+3 -3
View File
@@ -11,7 +11,7 @@ import (
// CreateVolume inserts a new volume configuration for a project.
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
vol.ID = uuid.New().String()
vol.CreatedAt = now()
vol.CreatedAt = Now()
vol.UpdatedAt = vol.CreatedAt
if vol.Mode == "" {
@@ -41,7 +41,7 @@ func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
}
defer rows.Close()
var vols []Volume
vols := []Volume{}
for rows.Next() {
vol, err := scanVolume(rows)
if err != nil {
@@ -71,7 +71,7 @@ func (s *Store) GetVolumeByID(id string) (Volume, error) {
// UpdateVolume updates an existing volume configuration.
func (s *Store) UpdateVolume(vol Volume) error {
vol.UpdatedAt = now()
vol.UpdatedAt = Now()
result, err := s.db.Exec(
`UPDATE volumes SET source=?, target=?, mode=?, updated_at=?
WHERE id=?`,
+4 -24
View File
@@ -3,10 +3,10 @@ package webhook
import (
"context"
"fmt"
"log"
"strconv"
"log/slog"
"strings"
"github.com/alexei/docker-watcher/internal/docker"
"github.com/alexei/docker-watcher/internal/store"
)
@@ -37,9 +37,9 @@ func AutoCreateProject(
if inspector != nil {
info, err := inspector.InspectImage(ctx, imageRef)
if err != nil {
log.Printf("[webhook] image inspection failed for %s (using defaults): %v", imageRef, err)
slog.Warn("webhook: image inspection failed, using defaults", "image", imageRef, "error", err)
} else {
port = extractPort(info.ExposedPorts)
port = docker.ExtractPort(info.ExposedPorts)
healthcheck = info.Healthcheck
}
}
@@ -89,23 +89,3 @@ func buildImageRef(parsed ParsedImage) string {
return ref
}
// extractPort parses the first exposed port from Docker EXPOSE entries.
// Entries are in the form "8080/tcp" or "8080". Returns 0 if none found.
func extractPort(exposedPorts []string) int {
if len(exposedPorts) == 0 {
return 0
}
// Take the first port entry.
raw := exposedPorts[0]
// Strip protocol suffix (e.g. "/tcp", "/udp").
if idx := strings.Index(raw, "/"); idx != -1 {
raw = raw[:idx]
}
port, err := strconv.Atoi(raw)
if err != nil {
return 0
}
return port
}
+32 -25
View File
@@ -5,7 +5,7 @@ import (
"crypto/subtle"
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"strings"
@@ -125,6 +125,18 @@ func (h *Handler) Route() chi.Router {
return r
}
// respondWebhookJSON writes a JSON response for webhook handlers.
func respondWebhookJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(data) //nolint:errcheck
}
// respondWebhookError writes a JSON error response for webhook handlers.
func respondWebhookError(w http.ResponseWriter, status int, msg string) {
respondWebhookJSON(w, status, map[string]any{"success": false, "error": msg})
}
// handleWebhook processes an incoming webhook request.
// URL format: POST /api/webhook/{secret-uuid}
// Returns 404 for invalid secrets (no information leak).
@@ -140,7 +152,7 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
// Validate the webhook secret against stored settings.
settings, err := h.store.GetSettings()
if err != nil {
log.Printf("[webhook] failed to read settings: %v", err)
slog.Error("webhook: failed to read settings", "error", err)
http.NotFound(w, r)
return
}
@@ -153,19 +165,18 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
// Parse the request body.
var payload Payload
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
http.Error(w, `{"error":"invalid JSON payload"}`, http.StatusBadRequest)
respondWebhookError(w, http.StatusBadRequest, "invalid JSON payload")
return
}
if payload.Image == "" {
http.Error(w, `{"error":"missing image field"}`, http.StatusBadRequest)
respondWebhookError(w, http.StatusBadRequest, "missing image field")
return
}
parsed, err := ParseImageRef(payload.Image)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
return
}
@@ -174,47 +185,43 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
parsed.Tag = "latest"
}
log.Printf("[webhook] received push for image %s:%s", parsed.FullName(), parsed.Tag)
slog.Info("webhook: received push", "image", parsed.FullName(), "tag", parsed.Tag)
// Look up a matching project by image name.
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
if err != nil {
log.Printf("[webhook] lookup error: %v", err)
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
slog.Error("webhook: lookup error", "error", err)
respondWebhookError(w, http.StatusInternalServerError, "internal error")
return
}
if !found {
// Unknown project — auto-create with defaults from image inspection.
log.Printf("[webhook] unknown image %s, auto-creating project", parsed.FullName())
slog.Info("webhook: unknown image, auto-creating project", "image", parsed.FullName())
project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed)
if err != nil {
log.Printf("[webhook] auto-create failed: %v", err)
http.Error(w, `{"error":"failed to auto-create project"}`, http.StatusInternalServerError)
slog.Error("webhook: auto-create failed", "error", err)
respondWebhookError(w, http.StatusInternalServerError, "failed to auto-create project")
return
}
log.Printf("[webhook] auto-created project %s (%s) with stage %s", project.Name, project.ID, stage.Name)
slog.Info("webhook: auto-created project", "project", project.Name, "id", project.ID, "stage", stage.Name)
}
// Only deploy if auto_deploy is enabled for the matched stage.
if !stage.AutoDeploy {
log.Printf("[webhook] auto_deploy disabled for project %s stage %s, skipping deploy", project.Name, stage.Name)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": false, "project": project.Name, "stage": stage.Name})
slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name)
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": false, "project": project.Name, "stage": stage.Name})
return
}
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
log.Printf("[webhook] deploy trigger failed: %v", err)
http.Error(w, `{"error":"deploy trigger failed"}`, http.StatusInternalServerError)
slog.Error("webhook: deploy trigger failed", "error", err)
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
return
}
log.Printf("[webhook] triggered deploy for project %s stage %s tag %s", project.Name, stage.Name, parsed.Tag)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
}
// EnsureWebhookSecret checks whether a webhook secret exists in settings.
@@ -234,7 +241,7 @@ func EnsureWebhookSecret(st *store.Store) (string, error) {
return "", fmt.Errorf("store webhook secret: %w", err)
}
log.Printf("[webhook] generated new webhook secret")
slog.Info("webhook: generated new secret")
return settings.WebhookSecret, nil
}
@@ -251,6 +258,6 @@ func RegenerateWebhookSecret(st *store.Store) (string, error) {
return "", fmt.Errorf("store webhook secret: %w", err)
}
log.Printf("[webhook] regenerated webhook secret")
slog.Info("webhook: regenerated secret")
return settings.WebhookSecret, nil
}