diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..9aa1823 --- /dev/null +++ b/.env.example @@ -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= diff --git a/README.md b/README.md new file mode 100644 index 0000000..7485edf --- /dev/null +++ b/README.md @@ -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/ \ + -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 diff --git a/cmd/server/main.go b/cmd/server/main.go index ec88781..b91e2e6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index 80722d6..196fb48 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/internal/api/auth.go b/internal/api/auth.go index a099cc8..43e0fd4 100644 --- a/internal/api/auth.go +++ b/internal/api/auth.go @@ -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 { diff --git a/internal/api/deploys.go b/internal/api/deploys.go index 46c2b82..a5091a4 100644 --- a/internal/api/deploys.go +++ b/internal/api/deploys.go @@ -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 -} diff --git a/internal/api/instances.go b/internal/api/instances.go index 81a3d9b..4b936bc 100644 --- a/internal/api/instances.go +++ b/internal/api/instances.go @@ -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}) diff --git a/internal/api/middleware.go b/internal/api/middleware.go index 07f7345..281a1f9 100644 --- a/internal/api/middleware.go +++ b/internal/api/middleware.go @@ -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) { diff --git a/internal/api/response.go b/internal/api/response.go index 2eedd8b..ea82ec4 100644 --- a/internal/api/response.go +++ b/internal/api/response.go @@ -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 { diff --git a/internal/api/router.go b/internal/api/router.go index 19144bf..f88fa25 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -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) + }) }) }) diff --git a/internal/api/sse.go b/internal/api/sse.go index fb2a2b5..4882223 100644 --- a/internal/api/sse.go +++ b/internal/api/sse.go @@ -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) } diff --git a/internal/api/volumes.go b/internal/api/volumes.go index 01c52e6..94605ac 100644 --- a/internal/api/volumes.go +++ b/internal/api/volumes.go @@ -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" } diff --git a/internal/auth/models.go b/internal/auth/models.go index 3a182aa..ae523fa 100644 --- a/internal/auth/models.go +++ b/internal/auth/models.go @@ -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"` diff --git a/internal/config/seed.go b/internal/config/seed.go index 5c27649..8bcb088 100644 --- a/internal/config/seed.go +++ b/internal/config/seed.go @@ -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 { diff --git a/internal/deployer/bluegreen.go b/internal/deployer/bluegreen.go index 84bf6b4..f0a8edc 100644 --- a/internal/deployer/bluegreen.go +++ b/internal/deployer/bluegreen.go @@ -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, diff --git a/internal/deployer/deployer.go b/internal/deployer/deployer.go index 503480b..9290f99 100644 --- a/internal/deployer/deployer.go +++ b/internal/deployer/deployer.go @@ -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 diff --git a/internal/docker/image.go b/internal/docker/image.go index bcc2793..da47584 100644 --- a/internal/docker/image.go +++ b/internal/docker/image.go @@ -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"]). diff --git a/internal/notify/notifier.go b/internal/notify/notifier.go index ddc4f2d..081c7e6 100644 --- a/internal/notify/notifier.go +++ b/internal/notify/notifier.go @@ -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) } }() } diff --git a/internal/registry/poller.go b/internal/registry/poller.go index e8e8b6b..3b60332 100644 --- a/internal/registry/poller.go +++ b/internal/registry/poller.go @@ -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") -} diff --git a/internal/store/deploys.go b/internal/store/deploys.go index 24e1e6c..057e16b 100644 --- a/internal/store/deploys.go +++ b/internal/store/deploys.go @@ -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 +} diff --git a/internal/store/instances.go b/internal/store/instances.go index 6203b12..2cb4cfb 100644 --- a/internal/store/instances.go +++ b/internal/store/instances.go @@ -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, diff --git a/internal/store/poll_state.go b/internal/store/poll_state.go index 7d23db4..702208b 100644 --- a/internal/store/poll_state.go +++ b/internal/store/poll_state.go @@ -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 { diff --git a/internal/store/projects.go b/internal/store/projects.go index 3d436f1..fbf2363 100644 --- a/internal/store/projects.go +++ b/internal/store/projects.go @@ -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=?`, diff --git a/internal/store/registries.go b/internal/store/registries.go index 08528cc..5fd35bb 100644 --- a/internal/store/registries.go +++ b/internal/store/registries.go @@ -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=?`, diff --git a/internal/store/settings.go b/internal/store/settings.go index 90b5354..05319e3 100644 --- a/internal/store/settings.go +++ b/internal/store/settings.go @@ -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=?, diff --git a/internal/store/stage_env.go b/internal/store/stage_env.go index cddd707..bd59563 100644 --- a/internal/store/stage_env.go +++ b/internal/store/stage_env.go @@ -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) diff --git a/internal/store/stages.go b/internal/store/stages.go index ada2c69..a483d7e 100644 --- a/internal/store/stages.go +++ b/internal/store/stages.go @@ -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 } diff --git a/internal/store/store.go b/internal/store/store.go index 8c5ca3a..872f366 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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") } diff --git a/internal/store/users.go b/internal/store/users.go index 266211b..d77953f 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -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, diff --git a/internal/store/volumes.go b/internal/store/volumes.go index 3ab1acd..2a2b754 100644 --- a/internal/store/volumes.go +++ b/internal/store/volumes.go @@ -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=?`, diff --git a/internal/webhook/autocreate.go b/internal/webhook/autocreate.go index 6cbaff5..67499e8 100644 --- a/internal/webhook/autocreate.go +++ b/internal/webhook/autocreate.go @@ -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 -} diff --git a/internal/webhook/handler.go b/internal/webhook/handler.go index 9c5628e..795d13b 100644 --- a/internal/webhook/handler.go +++ b/internal/webhook/handler.go @@ -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 }