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:
@@ -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=
|
||||||
@@ -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
@@ -49,6 +49,13 @@ func main() {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
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).
|
// Import seed config on first launch (idempotent).
|
||||||
seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml")
|
seedPath := envOrDefault("SEED_FILE", "./docker-watcher.yaml")
|
||||||
if err := config.ImportSeed(db, seedPath); err != nil {
|
if err := config.ImportSeed(db, seedPath); err != nil {
|
||||||
@@ -56,13 +63,6 @@ func main() {
|
|||||||
os.Exit(1)
|
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.
|
// Ensure default admin user exists on first launch.
|
||||||
if err := ensureDefaultAdmin(db); err != nil {
|
if err := ensureDefaultAdmin(db); err != nil {
|
||||||
slog.Error("ensure default admin", "error", err)
|
slog.Error("ensure default admin", "error", err)
|
||||||
@@ -116,7 +116,7 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Build API server.
|
// 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()
|
router := apiServer.Router()
|
||||||
|
|
||||||
// Serve embedded static files for the SPA frontend.
|
// Serve embedded static files for the SPA frontend.
|
||||||
@@ -160,8 +160,9 @@ func main() {
|
|||||||
// Stop accepting new work.
|
// Stop accepting new work.
|
||||||
poller.Stop()
|
poller.Stop()
|
||||||
|
|
||||||
// Drain in-progress deploys.
|
// Drain in-progress deploys and notifications.
|
||||||
dep.Drain()
|
dep.Drain()
|
||||||
|
notifier.Drain()
|
||||||
|
|
||||||
// Shut down HTTP server.
|
// Shut down HTTP server.
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
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.
|
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)
|
hash, err := auth.HashPassword(password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
+4
-2
@@ -16,8 +16,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
# Required: protects all credentials stored in the database.
|
# Required: protects all credentials stored in the database.
|
||||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY:?Set ENCRYPTION_KEY in .env}
|
||||||
# Optional: default admin password on first launch (default: "admin").
|
# Required on first launch: password for the default admin user.
|
||||||
- ADMIN_PASSWORD=${ADMIN_PASSWORD:-admin}
|
- ADMIN_PASSWORD=${ADMIN_PASSWORD:?Set ADMIN_PASSWORD in .env}
|
||||||
# Optional: override seed file location.
|
# Optional: override seed file location.
|
||||||
- SEED_FILE=/app/docker-watcher.yaml
|
- SEED_FILE=/app/docker-watcher.yaml
|
||||||
# Optional: override data directory.
|
# Optional: override data directory.
|
||||||
@@ -41,6 +41,8 @@ volumes:
|
|||||||
docker-watcher-data:
|
docker-watcher-data:
|
||||||
driver: local
|
driver: local
|
||||||
|
|
||||||
|
# NOTE: The staging-net network must exist before starting.
|
||||||
|
# Create it with: docker network create staging-net
|
||||||
networks:
|
networks:
|
||||||
staging-net:
|
staging-net:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
+33
-4
@@ -14,6 +14,21 @@ import (
|
|||||||
"github.com/alexei/docker-watcher/internal/store"
|
"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.
|
// login handles POST /api/auth/login.
|
||||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||||
var req auth.LoginRequest
|
var req auth.LoginRequest
|
||||||
@@ -32,7 +47,8 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusUnauthorized, "invalid credentials")
|
respondError(w, http.StatusUnauthorized, "invalid credentials")
|
||||||
return
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,6 +109,7 @@ func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
Path: "/api/auth/oidc",
|
Path: "/api/auth/oidc",
|
||||||
MaxAge: 300, // 5 minutes
|
MaxAge: 300, // 5 minutes
|
||||||
HttpOnly: true,
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
SameSite: http.SameSiteLaxMode,
|
SameSite: http.SameSiteLaxMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -177,9 +194,17 @@ func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redirect to frontend with token in query parameter.
|
// Set the token in a short-lived cookie the frontend can read once.
|
||||||
// The frontend extracts the token and stores it in localStorage.
|
http.SetCookie(w, &http.Cookie{
|
||||||
http.Redirect(w, r, "/?token="+token.Token, http.StatusFound)
|
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.
|
// getAuthSettings handles GET /api/auth/settings.
|
||||||
@@ -270,6 +295,10 @@ func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if req.Role == "" {
|
if req.Role == "" {
|
||||||
req.Role = "viewer"
|
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)
|
hash, err := auth.HashPassword(req.Password)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+2
-14
@@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/docker"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,7 +72,7 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
port := extractPort(info.ExposedPorts)
|
port := docker.ExtractPort(info.ExposedPorts)
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, inspectResponse{
|
respondJSON(w, http.StatusOK, inspectResponse{
|
||||||
Image: req.Image,
|
Image: req.Image,
|
||||||
@@ -165,16 +166,3 @@ func splitImageTag(ref string) (string, string) {
|
|||||||
return ref, ""
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"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.
|
// Delete instance record.
|
||||||
if err := s.store.DeleteInstance(instanceID); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID})
|
respondJSON(w, http.StatusOK, map[string]string{"deleted": instanceID})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
|
"sync"
|
||||||
"time"
|
"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 {
|
func cors(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
origin := r.Header.Get("Origin")
|
||||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
if origin != "" {
|
||||||
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
// 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 {
|
if r.Method == http.MethodOptions {
|
||||||
w.WriteHeader(http.StatusNoContent)
|
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.
|
// jsonContentType is an HTTP middleware that sets the default Content-Type to JSON.
|
||||||
func jsonContentType(next http.Handler) http.Handler {
|
func jsonContentType(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"reflect"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// envelope is the standard API response wrapper.
|
// 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.
|
// 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) {
|
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.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil {
|
if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil {
|
||||||
|
|||||||
+74
-59
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
"github.com/alexei/docker-watcher/internal/docker"
|
"github.com/alexei/docker-watcher/internal/docker"
|
||||||
"github.com/alexei/docker-watcher/internal/events"
|
"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/store"
|
||||||
"github.com/alexei/docker-watcher/internal/webhook"
|
"github.com/alexei/docker-watcher/internal/webhook"
|
||||||
)
|
)
|
||||||
@@ -18,6 +19,7 @@ import (
|
|||||||
type Server struct {
|
type Server struct {
|
||||||
store *store.Store
|
store *store.Store
|
||||||
docker *docker.Client
|
docker *docker.Client
|
||||||
|
npm *npm.Client
|
||||||
deployer DeployTriggerer
|
deployer DeployTriggerer
|
||||||
webhook *webhook.Handler
|
webhook *webhook.Handler
|
||||||
eventBus *events.Bus
|
eventBus *events.Bus
|
||||||
@@ -30,6 +32,7 @@ type Server struct {
|
|||||||
func NewServer(
|
func NewServer(
|
||||||
st *store.Store,
|
st *store.Store,
|
||||||
dockerClient *docker.Client,
|
dockerClient *docker.Client,
|
||||||
|
npmClient *npm.Client,
|
||||||
deployer DeployTriggerer,
|
deployer DeployTriggerer,
|
||||||
webhookHandler *webhook.Handler,
|
webhookHandler *webhook.Handler,
|
||||||
eventBus *events.Bus,
|
eventBus *events.Bus,
|
||||||
@@ -40,6 +43,7 @@ func NewServer(
|
|||||||
s := &Server{
|
s := &Server{
|
||||||
store: st,
|
store: st,
|
||||||
docker: dockerClient,
|
docker: dockerClient,
|
||||||
|
npm: npmClient,
|
||||||
deployer: deployer,
|
deployer: deployer,
|
||||||
webhook: webhookHandler,
|
webhook: webhookHandler,
|
||||||
eventBus: eventBus,
|
eventBus: eventBus,
|
||||||
@@ -86,15 +90,19 @@ func (s *Server) Router() chi.Router {
|
|||||||
|
|
||||||
// Global middleware.
|
// Global middleware.
|
||||||
r.Use(recovery)
|
r.Use(recovery)
|
||||||
|
r.Use(securityHeaders)
|
||||||
r.Use(logging)
|
r.Use(logging)
|
||||||
r.Use(cors)
|
r.Use(cors)
|
||||||
|
|
||||||
|
loginLimiter := newRateLimiter()
|
||||||
|
|
||||||
r.Route("/api", func(r chi.Router) {
|
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(jsonContentType)
|
||||||
|
r.Use(limitBody)
|
||||||
|
|
||||||
// Public auth endpoints (no auth required).
|
// 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/login", s.oidcLogin)
|
||||||
r.Get("/auth/oidc/callback", s.oidcCallback)
|
r.Get("/auth/oidc/callback", s.oidcCallback)
|
||||||
|
|
||||||
@@ -105,80 +113,87 @@ func (s *Server) Router() chi.Router {
|
|||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(auth.Middleware(s.localAuth))
|
r.Use(auth.Middleware(s.localAuth))
|
||||||
|
|
||||||
// Config export (protected — reveals project/infra details).
|
// Read-only endpoints (any authenticated user).
|
||||||
r.Get("/config/export", s.exportConfig)
|
|
||||||
|
|
||||||
// Auth management.
|
|
||||||
r.Get("/auth/me", s.currentUser)
|
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.Get("/projects", s.listProjects)
|
||||||
r.Post("/projects", s.createProject)
|
|
||||||
r.Route("/projects/{id}", func(r chi.Router) {
|
r.Route("/projects/{id}", func(r chi.Router) {
|
||||||
r.Get("/", s.getProject)
|
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.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.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.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", s.listDeploys)
|
||||||
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
|
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
|
||||||
|
|
||||||
// SSE endpoint for real-time instance status and deploy events.
|
|
||||||
r.Get("/events", s.streamEvents)
|
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.Get("/registries", s.listRegistries)
|
||||||
r.Post("/registries", s.createRegistry)
|
|
||||||
r.Route("/registries/{id}", func(r chi.Router) {
|
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("/tags/*", s.listRegistryTags)
|
||||||
r.Get("/images", s.listRegistryImages)
|
r.Get("/images", s.listRegistryImages)
|
||||||
})
|
})
|
||||||
|
|
||||||
// Settings endpoints.
|
|
||||||
r.Get("/settings", s.getSettings)
|
r.Get("/settings", s.getSettings)
|
||||||
r.Put("/settings", s.updateSettings)
|
|
||||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
// Admin-only routes: require admin role.
|
||||||
r.Post("/settings/webhook-url/regenerate", s.regenerateWebhookSecret)
|
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
@@ -183,10 +183,5 @@ func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
|
|||||||
|
|
||||||
// isTerminalStatus returns true if the deploy status is final.
|
// isTerminalStatus returns true if the deploy status is final.
|
||||||
func isTerminalStatus(status string) bool {
|
func isTerminalStatus(status string) bool {
|
||||||
switch status {
|
return store.IsTerminalDeployStatus(status)
|
||||||
case "success", "failed", "rolled_back":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,20 @@ package api
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"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.
|
// volumeRequest is the expected JSON body for creating/updating a volume.
|
||||||
type volumeRequest struct {
|
type volumeRequest struct {
|
||||||
Source string `json:"source"`
|
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")
|
respondError(w, http.StatusBadRequest, "target is required")
|
||||||
return
|
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 == "" {
|
if req.Mode == "" {
|
||||||
req.Mode = "shared"
|
req.Mode = "shared"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,26 +2,6 @@ package auth
|
|||||||
|
|
||||||
import "time"
|
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.
|
// Claims represents the JWT token claims.
|
||||||
type Claims struct {
|
type Claims struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
|
|||||||
+7
-21
@@ -3,9 +3,8 @@ package config
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/alexei/docker-watcher/internal/crypto"
|
"github.com/alexei/docker-watcher/internal/crypto"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
@@ -17,7 +16,7 @@ import (
|
|||||||
// Credential fields (registry tokens, NPM password) are encrypted before storage.
|
// Credential fields (registry tokens, NPM password) are encrypted before storage.
|
||||||
func ImportSeed(db *store.Store, seedPath string) error {
|
func ImportSeed(db *store.Store, seedPath string) error {
|
||||||
if _, err := os.Stat(seedPath); os.IsNotExist(err) {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,7 +25,7 @@ func ImportSeed(db *store.Store, seedPath string) error {
|
|||||||
return fmt.Errorf("check if db is populated: %w", err)
|
return fmt.Errorf("check if db is populated: %w", err)
|
||||||
}
|
}
|
||||||
if populated {
|
if populated {
|
||||||
log.Println("Database already has data, skipping seed import")
|
slog.Info("database already has data, skipping seed import")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,7 +43,7 @@ func ImportSeed(db *store.Store, seedPath string) error {
|
|||||||
return fmt.Errorf("import seed: %w", err)
|
return fmt.Errorf("import seed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Seed config imported from %s", seedPath)
|
slog.Info("seed config imported", "path", seedPath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,11 +64,6 @@ func isPopulated(db *store.Store) (bool, error) {
|
|||||||
return len(registries) > 0, nil
|
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.
|
// importAll runs the full seed import inside a database transaction.
|
||||||
// Uses raw SQL within the transaction so all inserts are atomic.
|
// Uses raw SQL within the transaction so all inserts are atomic.
|
||||||
func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
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
|
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.
|
// Import registries first — projects reference them by name.
|
||||||
for name, regDef := range cfg.Registries {
|
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)
|
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
stageID, projectID, stageName, stageDef.TagPattern,
|
stageID, projectID, stageName, stageDef.TagPattern,
|
||||||
boolToInt(stageDef.AutoDeploy), maxInstances,
|
store.BoolToInt(stageDef.AutoDeploy), maxInstances,
|
||||||
boolToInt(stageDef.Confirm), stageDef.PromoteFrom,
|
store.BoolToInt(stageDef.Confirm), stageDef.PromoteFrom,
|
||||||
stageDef.Subdomain, timestamp, timestamp,
|
stageDef.Subdomain, timestamp, timestamp,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -173,14 +167,6 @@ func importAll(db *store.Store, cfg SeedConfig, encKey [32]byte) error {
|
|||||||
return nil
|
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.
|
// mapToJSON encodes a string map to JSON. Returns "{}" for nil maps.
|
||||||
func mapToJSON(m map[string]string) (string, error) {
|
func mapToJSON(m map[string]string) (string, error) {
|
||||||
if m == nil {
|
if m == nil {
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func (d *Deployer) blueGreenDeploy(
|
|||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
||||||
envVars := d.mergeEnvVars(project, stage.ID)
|
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{
|
containerCfg := docker.ContainerConfig{
|
||||||
Name: containerName,
|
Name: containerName,
|
||||||
|
|||||||
+12
-113
@@ -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).
|
// 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.
|
// It validates inputs, creates a deploy record, and delegates to runDeploy.
|
||||||
// On failure, it rolls back (removes container, deletes proxy host, updates status).
|
|
||||||
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
|
func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageTag string) error {
|
||||||
if d.shuttingDown.Load() {
|
if d.shuttingDown.Load() {
|
||||||
return fmt.Errorf("deployer is shutting down, rejecting new deploy")
|
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)
|
d.activeWg.Add(1)
|
||||||
defer d.activeWg.Done()
|
defer d.activeWg.Done()
|
||||||
|
|
||||||
// Load project and stage from store.
|
|
||||||
project, err := d.store.GetProjectByID(projectID)
|
project, err := d.store.GetProjectByID(projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get project: %w", err)
|
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)
|
return fmt.Errorf("get stage: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate promote_from constraint.
|
|
||||||
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
|
if err := d.validatePromoteFrom(stage, imageTag); err != nil {
|
||||||
return fmt.Errorf("promote validation: %w", err)
|
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{
|
deploy, err := d.store.CreateDeploy(store.Deploy{
|
||||||
ProjectID: projectID,
|
ProjectID: projectID,
|
||||||
StageID: stageID,
|
StageID: stageID,
|
||||||
@@ -234,69 +225,9 @@ func (d *Deployer) TriggerDeploy(ctx context.Context, projectID, stageID, imageT
|
|||||||
return fmt.Errorf("create deploy record: %w", err)
|
return fmt.Errorf("create deploy record: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("starting deploy",
|
if err := d.runDeploy(ctx, project, stage, deploy.ID, imageTag); err != nil {
|
||||||
"deploy_id", deploy.ID,
|
return err
|
||||||
"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.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,7 +282,7 @@ func (d *Deployer) executeDeploy(
|
|||||||
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
containerName := docker.ContainerName(project.Name, stage.Name, imageTag)
|
||||||
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
portStr := fmt.Sprintf("%d/tcp", project.Port)
|
||||||
envVars := d.mergeEnvVars(project, stage.ID)
|
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{
|
containerCfg := docker.ContainerConfig{
|
||||||
Name: containerName,
|
Name: containerName,
|
||||||
@@ -597,25 +528,18 @@ func (d *Deployer) buildRegistryAuth(project store.Project) (string, error) {
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
registries, err := d.store.GetAllRegistries()
|
reg, err := d.store.GetRegistryByName(project.Registry)
|
||||||
if err != nil {
|
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.Token != "" {
|
||||||
if reg.Name == project.Registry {
|
decrypted, err := crypto.Decrypt(d.encKey, reg.Token)
|
||||||
token := reg.Token
|
if err != nil {
|
||||||
if token != "" {
|
return "", fmt.Errorf("decrypt registry token: %w", err)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
return docker.EncodeRegistryAuth(decrypted, decrypted, reg.URL)
|
||||||
}
|
}
|
||||||
|
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -628,25 +552,6 @@ func (d *Deployer) decryptNpmPassword(encryptedPassword string) (string, error)
|
|||||||
return crypto.Decrypt(d.encKey, encryptedPassword)
|
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:
|
// mergeEnvVars builds the final environment variable list for a container:
|
||||||
// 1. Parse project-level env JSON
|
// 1. Parse project-level env JSON
|
||||||
// 2. Overlay with stage-level env overrides (stage wins on key conflict)
|
// 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.
|
// computeVolumeMounts builds Docker mount specifications from the project's volume config.
|
||||||
// For shared mode, source is used as-is.
|
// For shared mode, source is used as-is.
|
||||||
// For isolated mode, source gets /{stage}-{tag}/ appended.
|
// 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)
|
vols, err := d.store.GetVolumesByProjectID(projectID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("get project volumes", "project_id", projectID, "error", err)
|
slog.Warn("get project volumes", "project_id", projectID, "error", err)
|
||||||
@@ -707,12 +612,6 @@ func (d *Deployer) computeVolumeMounts(projectID, stageName, imageTag string) []
|
|||||||
return nil
|
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))
|
mounts := make([]mount.Mount, 0, len(vols))
|
||||||
for _, vol := range vols {
|
for _, vol := range vols {
|
||||||
source := vol.Source
|
source := vol.Source
|
||||||
|
|||||||
@@ -5,12 +5,27 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/moby/moby/api/types/registry"
|
"github.com/moby/moby/api/types/registry"
|
||||||
"github.com/moby/moby/client"
|
"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.
|
// ImageInfo holds metadata extracted from a Docker image inspection.
|
||||||
type ImageInfo struct {
|
type ImageInfo struct {
|
||||||
// ExposedPorts lists the ports declared via EXPOSE in the Dockerfile (e.g. ["8080/tcp"]).
|
// ExposedPorts lists the ports declared via EXPOSE in the Dockerfile (e.g. ["8080/tcp"]).
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ type Event struct {
|
|||||||
// Notifications are fire-and-forget — failures are logged but do not propagate.
|
// Notifications are fire-and-forget — failures are logged but do not propagate.
|
||||||
type Notifier struct {
|
type Notifier struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
wg sync.WaitGroup
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Notifier with sensible defaults.
|
// 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.
|
// 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.
|
// It does not block the caller. Errors are logged, not returned.
|
||||||
func (n *Notifier) Send(webhookURL string, event Event) {
|
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)
|
event.Timestamp = time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
n.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
if err := n.doSend(context.Background(), webhookURL, event); err != nil {
|
defer n.wg.Done()
|
||||||
log.Printf("notify: failed to send webhook to %s: %v", webhookURL, err)
|
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
@@ -3,7 +3,7 @@ package registry
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ func (p *Poller) Start(interval string) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if pollErr := p.poll(ctx); pollErr != nil {
|
if pollErr := p.poll(ctx); pollErr != nil {
|
||||||
log.Printf("[poller] poll error: %v", pollErr)
|
slog.Warn("poller: poll error", "error", pollErr)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,7 +69,7 @@ func (p *Poller) Start(interval string) error {
|
|||||||
}
|
}
|
||||||
p.running = true
|
p.running = true
|
||||||
|
|
||||||
log.Printf("[poller] started with interval %s", duration)
|
slog.Info("poller started", "interval", duration.String())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ func (p *Poller) Stop() {
|
|||||||
ctx := p.cron.Stop()
|
ctx := p.cron.Stop()
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
p.running = false
|
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 {
|
for _, project := range projects {
|
||||||
if err := p.pollProject(ctx, project); err != nil {
|
if err := p.pollProject(ctx, project); err != nil {
|
||||||
log.Printf("[poller] project %s (%s): %v", project.Name, project.ID, err)
|
slog.Warn("poller: project error", "project", project.Name, "id", project.ID, "error", err)
|
||||||
// Continue polling other projects even if one fails.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -109,32 +108,26 @@ func (p *Poller) pollProject(ctx context.Context, project store.Project) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Look up the registry configuration by name (projects store registry name, not ID).
|
|
||||||
reg, err := p.store.GetRegistryByName(project.Registry)
|
reg, err := p.store.GetRegistryByName(project.Registry)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get registry %s: %w", project.Registry, err)
|
return fmt.Errorf("get registry %s: %w", project.Registry, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decrypt the registry token.
|
|
||||||
token, err := crypto.Decrypt(p.encKey, reg.Token)
|
token, err := crypto.Decrypt(p.encKey, reg.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Token might not be encrypted (empty or plaintext).
|
|
||||||
token = reg.Token
|
token = reg.Token
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a registry client for this registry type.
|
|
||||||
client, err := NewClient(reg.Type, reg.URL, token)
|
client, err := NewClient(reg.Type, reg.URL, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create registry client: %w", err)
|
return fmt.Errorf("create registry client: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch all available tags for the project image.
|
|
||||||
tags, err := client.ListTags(ctx, project.Image)
|
tags, err := client.ListTags(ctx, project.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("list tags for %s: %w", project.Image, err)
|
return fmt.Errorf("list tags for %s: %w", project.Image, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check each stage of the project.
|
|
||||||
stages, err := p.store.GetStagesByProjectID(project.ID)
|
stages, err := p.store.GetStagesByProjectID(project.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get stages for project %s: %w", project.ID, err)
|
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 {
|
for _, stage := range stages {
|
||||||
if err := p.pollStage(ctx, project, stage, tags); err != nil {
|
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
|
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.
|
// 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 {
|
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)
|
latest, err := LatestTag(allTags, stage.TagPattern)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("match tags for stage %s: %w", stage.Name, err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the last polled tag for this stage.
|
|
||||||
state, err := p.store.GetPollState(stage.ID)
|
state, err := p.store.GetPollState(stage.ID)
|
||||||
if err != nil {
|
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{
|
return p.store.UpsertPollState(store.PollState{
|
||||||
StageID: stage.ID,
|
StageID: stage.ID,
|
||||||
LastTag: latest,
|
LastTag: latest,
|
||||||
LastPolled: now(),
|
LastPolled: store.Now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the poll timestamp regardless.
|
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := p.store.UpsertPollState(store.PollState{
|
if err := p.store.UpsertPollState(store.PollState{
|
||||||
StageID: stage.ID,
|
StageID: stage.ID,
|
||||||
LastTag: latest,
|
LastTag: latest,
|
||||||
LastPolled: now(),
|
LastPolled: store.Now(),
|
||||||
}); err != nil {
|
}); 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 {
|
if state.LastTag == latest {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[poller] new tag %q detected for project %s stage %s (was %q)",
|
slog.Info("poller: new tag detected", "tag", latest, "project", project.Name, "stage", stage.Name, "previous", state.LastTag)
|
||||||
latest, project.Name, stage.Name, state.LastTag)
|
|
||||||
|
|
||||||
// Only trigger deploy if auto_deploy is enabled for this stage.
|
|
||||||
if !stage.AutoDeploy {
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,8 +187,3 @@ func (p *Poller) pollStage(ctx context.Context, project store.Project, stage sto
|
|||||||
|
|
||||||
return nil
|
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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// CreateDeploy inserts a new deploy record.
|
// CreateDeploy inserts a new deploy record.
|
||||||
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
|
func (s *Store) CreateDeploy(d Deploy) (Deploy, error) {
|
||||||
d.ID = uuid.New().String()
|
d.ID = uuid.New().String()
|
||||||
d.StartedAt = now()
|
d.StartedAt = Now()
|
||||||
if d.Status == "" {
|
if d.Status == "" {
|
||||||
d.Status = "pending"
|
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.
|
// UpdateDeployStatus sets the status (and optionally error and finished_at) on a deploy.
|
||||||
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
|
func (s *Store) UpdateDeployStatus(id string, status string, deployErr string) error {
|
||||||
ts := now()
|
ts := Now()
|
||||||
var finishedAt string
|
var finishedAt string
|
||||||
if isTerminalDeployStatus(status) {
|
if IsTerminalDeployStatus(status) {
|
||||||
finishedAt = ts
|
finishedAt = ts
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ func (s *Store) AppendDeployLog(deployID string, message string, level string) e
|
|||||||
}
|
}
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
|
`INSERT INTO deploy_logs (deploy_id, message, level, created_at) VALUES (?, ?, ?, ?)`,
|
||||||
deployID, message, level, now(),
|
deployID, message, level, Now(),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("append deploy log: %w", err)
|
return fmt.Errorf("append deploy log: %w", err)
|
||||||
@@ -132,7 +132,7 @@ func (s *Store) GetDeployLogs(deployID string) ([]DeployLog, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var logs []DeployLog
|
logs := []DeployLog{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var l DeployLog
|
var l DeployLog
|
||||||
if err := rows.Scan(&l.ID, &l.DeployID, &l.Message, &l.Level, &l.CreatedAt); err != nil {
|
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.
|
// scanDeploys is a helper that scans deploy rows from a cursor.
|
||||||
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
|
func scanDeploys(rows *sql.Rows) ([]Deploy, error) {
|
||||||
var deploys []Deploy
|
deploys := []Deploy{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var d Deploy
|
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 {
|
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()
|
return deploys, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTerminalDeployStatus returns true if the status indicates the deploy is finished.
|
// IsTerminalDeployStatus returns true if the status indicates the deploy is finished.
|
||||||
func isTerminalDeployStatus(status string) bool {
|
func IsTerminalDeployStatus(status string) bool {
|
||||||
switch status {
|
switch status {
|
||||||
case "success", "failed", "rolled_back":
|
case "success", "failed", "rolled_back":
|
||||||
return true
|
return true
|
||||||
@@ -165,3 +165,17 @@ func isTerminalDeployStatus(status string) bool {
|
|||||||
return false
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// CreateInstance inserts a new instance record.
|
// CreateInstance inserts a new instance record.
|
||||||
func (s *Store) CreateInstance(inst Instance) (Instance, error) {
|
func (s *Store) CreateInstance(inst Instance) (Instance, error) {
|
||||||
inst.ID = uuid.New().String()
|
inst.ID = uuid.New().String()
|
||||||
inst.CreatedAt = now()
|
inst.CreatedAt = Now()
|
||||||
inst.UpdatedAt = inst.CreatedAt
|
inst.UpdatedAt = inst.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
@@ -32,7 +32,7 @@ func (s *Store) CreateInstanceWithID(inst Instance) (Instance, error) {
|
|||||||
if inst.ID == "" {
|
if inst.ID == "" {
|
||||||
return Instance{}, fmt.Errorf("instance ID is required")
|
return Instance{}, fmt.Errorf("instance ID is required")
|
||||||
}
|
}
|
||||||
inst.CreatedAt = now()
|
inst.CreatedAt = Now()
|
||||||
inst.UpdatedAt = inst.CreatedAt
|
inst.UpdatedAt = inst.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
@@ -75,7 +75,7 @@ func (s *Store) GetInstancesByStageID(stageID string) ([]Instance, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var instances []Instance
|
instances := []Instance{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var inst Instance
|
var inst Instance
|
||||||
if err := rows.Scan(&inst.ID, &inst.StageID, &inst.ProjectID, &inst.ContainerID, &inst.ImageTag,
|
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.
|
// UpdateInstance updates an existing instance's mutable fields.
|
||||||
func (s *Store) UpdateInstance(inst Instance) error {
|
func (s *Store) UpdateInstance(inst Instance) error {
|
||||||
inst.UpdatedAt = now()
|
inst.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, status=?, port=?, updated_at=?
|
`UPDATE instances SET stage_id=?, project_id=?, container_id=?, image_tag=?, subdomain=?, npm_proxy_id=?, status=?, port=?, updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
@@ -108,7 +108,7 @@ func (s *Store) UpdateInstance(inst Instance) error {
|
|||||||
|
|
||||||
// UpdateInstanceStatus sets only the status field on an instance.
|
// UpdateInstanceStatus sets only the status field on an instance.
|
||||||
func (s *Store) UpdateInstanceStatus(id string, status string) error {
|
func (s *Store) UpdateInstanceStatus(id string, status string) error {
|
||||||
ts := now()
|
ts := Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE instances SET status=?, updated_at=? WHERE id=?`,
|
`UPDATE instances SET status=?, updated_at=? WHERE id=?`,
|
||||||
status, ts, id,
|
status, ts, id,
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ func (s *Store) GetAllPollStates() ([]PollState, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var states []PollState
|
states := []PollState{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var ps PollState
|
var ps PollState
|
||||||
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
|
if err := rows.Scan(&ps.StageID, &ps.LastTag, &ps.LastPolled); err != nil {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// CreateProject inserts a new project and returns it.
|
// CreateProject inserts a new project and returns it.
|
||||||
func (s *Store) CreateProject(p Project) (Project, error) {
|
func (s *Store) CreateProject(p Project) (Project, error) {
|
||||||
p.ID = uuid.New().String()
|
p.ID = uuid.New().String()
|
||||||
p.CreatedAt = now()
|
p.CreatedAt = Now()
|
||||||
p.UpdatedAt = p.CreatedAt
|
p.UpdatedAt = p.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
@@ -52,7 +52,7 @@ func (s *Store) GetAllProjects() ([]Project, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var projects []Project
|
projects := []Project{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var p Project
|
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 {
|
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.
|
// UpdateProject updates an existing project's mutable fields.
|
||||||
func (s *Store) UpdateProject(p Project) error {
|
func (s *Store) UpdateProject(p Project) error {
|
||||||
p.UpdatedAt = now()
|
p.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, updated_at=?
|
`UPDATE projects SET name=?, registry=?, image=?, port=?, healthcheck=?, env=?, volumes=?, updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// CreateRegistry inserts a new registry.
|
// CreateRegistry inserts a new registry.
|
||||||
func (s *Store) CreateRegistry(r Registry) (Registry, error) {
|
func (s *Store) CreateRegistry(r Registry) (Registry, error) {
|
||||||
r.ID = uuid.New().String()
|
r.ID = uuid.New().String()
|
||||||
r.CreatedAt = now()
|
r.CreatedAt = Now()
|
||||||
r.UpdatedAt = r.CreatedAt
|
r.UpdatedAt = r.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
@@ -68,7 +68,7 @@ func (s *Store) GetAllRegistries() ([]Registry, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var registries []Registry
|
registries := []Registry{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var r Registry
|
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 {
|
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.
|
// UpdateRegistry updates an existing registry's mutable fields.
|
||||||
func (s *Store) UpdateRegistry(r Registry) error {
|
func (s *Store) UpdateRegistry(r Registry) error {
|
||||||
r.UpdatedAt = now()
|
r.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE registries SET name=?, url=?, type=?, token=?, owner=?, updated_at=?
|
`UPDATE registries SET name=?, url=?, type=?, token=?, owner=?, updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func (s *Store) GetSettings() (Settings, error) {
|
|||||||
|
|
||||||
// UpdateSettings upserts the global settings row.
|
// UpdateSettings upserts the global settings row.
|
||||||
func (s *Store) UpdateSettings(st Settings) error {
|
func (s *Store) UpdateSettings(st Settings) error {
|
||||||
st.UpdatedAt = now()
|
st.UpdatedAt = Now()
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`UPDATE settings SET
|
`UPDATE settings SET
|
||||||
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
domain=?, server_ip=?, network=?, subdomain_pattern=?, notification_url=?,
|
||||||
|
|||||||
@@ -11,13 +11,13 @@ import (
|
|||||||
// CreateStageEnv inserts a new stage environment variable override.
|
// CreateStageEnv inserts a new stage environment variable override.
|
||||||
func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) {
|
func (s *Store) CreateStageEnv(env StageEnv) (StageEnv, error) {
|
||||||
env.ID = uuid.New().String()
|
env.ID = uuid.New().String()
|
||||||
env.CreatedAt = now()
|
env.CreatedAt = Now()
|
||||||
env.UpdatedAt = env.CreatedAt
|
env.UpdatedAt = env.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
`INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at)
|
`INSERT INTO stage_env (id, stage_id, key, value, encrypted, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
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,
|
env.CreatedAt, env.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -37,7 +37,7 @@ func (s *Store) GetStageEnvByStageID(stageID string) ([]StageEnv, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var envs []StageEnv
|
envs := []StageEnv{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
env, err := scanStageEnv(rows)
|
env, err := scanStageEnv(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -69,11 +69,11 @@ func (s *Store) GetStageEnvByID(id string) (StageEnv, error) {
|
|||||||
|
|
||||||
// UpdateStageEnv updates an existing stage environment variable override.
|
// UpdateStageEnv updates an existing stage environment variable override.
|
||||||
func (s *Store) UpdateStageEnv(env StageEnv) error {
|
func (s *Store) UpdateStageEnv(env StageEnv) error {
|
||||||
env.UpdatedAt = now()
|
env.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=?
|
`UPDATE stage_env SET key=?, value=?, encrypted=?, updated_at=?
|
||||||
WHERE id=?`,
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("update stage env: %w", err)
|
return fmt.Errorf("update stage env: %w", err)
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import (
|
|||||||
// CreateStage inserts a new stage for a project.
|
// CreateStage inserts a new stage for a project.
|
||||||
func (s *Store) CreateStage(st Stage) (Stage, error) {
|
func (s *Store) CreateStage(st Stage) (Stage, error) {
|
||||||
st.ID = uuid.New().String()
|
st.ID = uuid.New().String()
|
||||||
st.CreatedAt = now()
|
st.CreatedAt = Now()
|
||||||
st.UpdatedAt = st.CreatedAt
|
st.UpdatedAt = st.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, 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)
|
`INSERT INTO stages (id, project_id, name, tag_pattern, auto_deploy, max_instances, confirm, promote_from, subdomain, created_at, updated_at)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
st.ID, st.ProjectID, st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances,
|
st.ID, st.ProjectID, st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
|
||||||
boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt,
|
BoolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.CreatedAt, st.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Stage{}, fmt.Errorf("insert stage: %w", err)
|
return Stage{}, fmt.Errorf("insert stage: %w", err)
|
||||||
@@ -37,7 +37,7 @@ func (s *Store) GetStagesByProjectID(projectID string) ([]Stage, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var stages []Stage
|
stages := []Stage{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
st, err := scanStage(rows)
|
st, err := scanStage(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -70,12 +70,12 @@ func (s *Store) GetStageByID(id string) (Stage, error) {
|
|||||||
|
|
||||||
// UpdateStage updates an existing stage's mutable fields.
|
// UpdateStage updates an existing stage's mutable fields.
|
||||||
func (s *Store) UpdateStage(st Stage) error {
|
func (s *Store) UpdateStage(st Stage) error {
|
||||||
st.UpdatedAt = now()
|
st.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, promote_from=?, subdomain=?, updated_at=?
|
`UPDATE stages SET name=?, tag_pattern=?, auto_deploy=?, max_instances=?, confirm=?, promote_from=?, subdomain=?, updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
st.Name, st.TagPattern, boolToInt(st.AutoDeploy), st.MaxInstances,
|
st.Name, st.TagPattern, BoolToInt(st.AutoDeploy), st.MaxInstances,
|
||||||
boolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID,
|
BoolToInt(st.Confirm), st.PromoteFrom, st.Subdomain, st.UpdatedAt, st.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("update stage: %w", err)
|
return fmt.Errorf("update stage: %w", err)
|
||||||
@@ -100,8 +100,8 @@ func (s *Store) DeleteStage(id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// boolToInt converts a bool to an integer for SQLite storage.
|
// BoolToInt converts a bool to an integer for SQLite storage.
|
||||||
func boolToInt(b bool) int {
|
func BoolToInt(b bool) int {
|
||||||
if b {
|
if b {
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -24,6 +24,10 @@ func New(dbPath string) (*Store, error) {
|
|||||||
return nil, fmt.Errorf("open database: %w", err)
|
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.
|
// Enable WAL mode and foreign keys for better concurrency and referential integrity.
|
||||||
pragmas := []string{
|
pragmas := []string{
|
||||||
"PRAGMA journal_mode=WAL",
|
"PRAGMA journal_mode=WAL",
|
||||||
@@ -79,6 +83,24 @@ func (s *Store) runMigrations() error {
|
|||||||
// Ignore errors from already-applied migrations (duplicate column).
|
// Ignore errors from already-applied migrations (duplicate column).
|
||||||
_, _ = s.db.Exec(m)
|
_, _ = 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,7 +246,7 @@ CREATE TABLE IF NOT EXISTS volumes (
|
|||||||
);
|
);
|
||||||
`
|
`
|
||||||
|
|
||||||
// now returns the current time formatted for SQLite storage.
|
// Now returns the current time formatted for SQLite storage.
|
||||||
func now() string {
|
func Now() string {
|
||||||
return time.Now().UTC().Format("2006-01-02 15:04:05")
|
return time.Now().UTC().Format("2006-01-02 15:04:05")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ type AuthSettings struct {
|
|||||||
// CreateUser inserts a new user record.
|
// CreateUser inserts a new user record.
|
||||||
func (s *Store) CreateUser(u User) (User, error) {
|
func (s *Store) CreateUser(u User) (User, error) {
|
||||||
u.ID = uuid.New().String()
|
u.ID = uuid.New().String()
|
||||||
u.CreatedAt = now()
|
u.CreatedAt = Now()
|
||||||
u.UpdatedAt = u.CreatedAt
|
u.UpdatedAt = u.CreatedAt
|
||||||
|
|
||||||
_, err := s.db.Exec(
|
_, err := s.db.Exec(
|
||||||
@@ -88,7 +88,7 @@ func (s *Store) GetAllUsers() ([]User, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var users []User
|
users := []User{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var u User
|
var u User
|
||||||
if err := rows.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.Email, &u.Role, &u.CreatedAt, &u.UpdatedAt); err != nil {
|
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).
|
// UpdateUser updates a user's mutable fields (username, email, role).
|
||||||
func (s *Store) UpdateUser(u User) error {
|
func (s *Store) UpdateUser(u User) error {
|
||||||
u.UpdatedAt = now()
|
u.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE users SET username=?, email=?, role=?, updated_at=? WHERE id=?`,
|
`UPDATE users SET username=?, email=?, role=?, updated_at=? WHERE id=?`,
|
||||||
u.Username, u.Email, u.Role, u.UpdatedAt, u.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.
|
// UpdateUserPassword updates a user's password hash.
|
||||||
func (s *Store) UpdateUserPassword(id string, passwordHash string) error {
|
func (s *Store) UpdateUserPassword(id string, passwordHash string) error {
|
||||||
ts := now()
|
ts := Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE users SET password_hash=?, updated_at=? WHERE id=?`,
|
`UPDATE users SET password_hash=?, updated_at=? WHERE id=?`,
|
||||||
passwordHash, ts, id,
|
passwordHash, ts, id,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
// CreateVolume inserts a new volume configuration for a project.
|
// CreateVolume inserts a new volume configuration for a project.
|
||||||
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
|
func (s *Store) CreateVolume(vol Volume) (Volume, error) {
|
||||||
vol.ID = uuid.New().String()
|
vol.ID = uuid.New().String()
|
||||||
vol.CreatedAt = now()
|
vol.CreatedAt = Now()
|
||||||
vol.UpdatedAt = vol.CreatedAt
|
vol.UpdatedAt = vol.CreatedAt
|
||||||
|
|
||||||
if vol.Mode == "" {
|
if vol.Mode == "" {
|
||||||
@@ -41,7 +41,7 @@ func (s *Store) GetVolumesByProjectID(projectID string) ([]Volume, error) {
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var vols []Volume
|
vols := []Volume{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
vol, err := scanVolume(rows)
|
vol, err := scanVolume(rows)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -71,7 +71,7 @@ func (s *Store) GetVolumeByID(id string) (Volume, error) {
|
|||||||
|
|
||||||
// UpdateVolume updates an existing volume configuration.
|
// UpdateVolume updates an existing volume configuration.
|
||||||
func (s *Store) UpdateVolume(vol Volume) error {
|
func (s *Store) UpdateVolume(vol Volume) error {
|
||||||
vol.UpdatedAt = now()
|
vol.UpdatedAt = Now()
|
||||||
result, err := s.db.Exec(
|
result, err := s.db.Exec(
|
||||||
`UPDATE volumes SET source=?, target=?, mode=?, updated_at=?
|
`UPDATE volumes SET source=?, target=?, mode=?, updated_at=?
|
||||||
WHERE id=?`,
|
WHERE id=?`,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ package webhook
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/alexei/docker-watcher/internal/docker"
|
||||||
"github.com/alexei/docker-watcher/internal/store"
|
"github.com/alexei/docker-watcher/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -37,9 +37,9 @@ func AutoCreateProject(
|
|||||||
if inspector != nil {
|
if inspector != nil {
|
||||||
info, err := inspector.InspectImage(ctx, imageRef)
|
info, err := inspector.InspectImage(ctx, imageRef)
|
||||||
if err != nil {
|
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 {
|
} else {
|
||||||
port = extractPort(info.ExposedPorts)
|
port = docker.ExtractPort(info.ExposedPorts)
|
||||||
healthcheck = info.Healthcheck
|
healthcheck = info.Healthcheck
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,23 +89,3 @@ func buildImageRef(parsed ParsedImage) string {
|
|||||||
return ref
|
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
@@ -5,7 +5,7 @@ import (
|
|||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -125,6 +125,18 @@ func (h *Handler) Route() chi.Router {
|
|||||||
return r
|
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.
|
// handleWebhook processes an incoming webhook request.
|
||||||
// URL format: POST /api/webhook/{secret-uuid}
|
// URL format: POST /api/webhook/{secret-uuid}
|
||||||
// Returns 404 for invalid secrets (no information leak).
|
// 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.
|
// Validate the webhook secret against stored settings.
|
||||||
settings, err := h.store.GetSettings()
|
settings, err := h.store.GetSettings()
|
||||||
if err != nil {
|
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)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -153,19 +165,18 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Parse the request body.
|
// Parse the request body.
|
||||||
var payload Payload
|
var payload Payload
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Image == "" {
|
if payload.Image == "" {
|
||||||
http.Error(w, `{"error":"missing image field"}`, http.StatusBadRequest)
|
respondWebhookError(w, http.StatusBadRequest, "missing image field")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, err := ParseImageRef(payload.Image)
|
parsed, err := ParseImageRef(payload.Image)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
respondWebhookError(w, http.StatusBadRequest, "invalid image reference")
|
||||||
json.NewEncoder(w).Encode(map[string]string{"error": err.Error()})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,47 +185,43 @@ func (h *Handler) handleWebhook(w http.ResponseWriter, r *http.Request) {
|
|||||||
parsed.Tag = "latest"
|
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.
|
// Look up a matching project by image name.
|
||||||
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
|
project, stage, found, err := FindProjectAndStage(ctx, h.store, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[webhook] lookup error: %v", err)
|
slog.Error("webhook: lookup error", "error", err)
|
||||||
http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError)
|
respondWebhookError(w, http.StatusInternalServerError, "internal error")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
if !found {
|
||||||
// Unknown project — auto-create with defaults from image inspection.
|
// 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)
|
project, stage, err = AutoCreateProject(ctx, h.store, h.inspector, parsed)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[webhook] auto-create failed: %v", err)
|
slog.Error("webhook: auto-create failed", "error", err)
|
||||||
http.Error(w, `{"error":"failed to auto-create project"}`, http.StatusInternalServerError)
|
respondWebhookError(w, http.StatusInternalServerError, "failed to auto-create project")
|
||||||
return
|
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.
|
// Only deploy if auto_deploy is enabled for the matched stage.
|
||||||
if !stage.AutoDeploy {
|
if !stage.AutoDeploy {
|
||||||
log.Printf("[webhook] auto_deploy disabled for project %s stage %s, skipping deploy", project.Name, stage.Name)
|
slog.Info("webhook: auto_deploy disabled, skipping", "project", project.Name, "stage", stage.Name)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": false, "project": project.Name, "stage": stage.Name})
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": false, "project": project.Name, "stage": stage.Name})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
|
if err := h.deployer.TriggerDeploy(ctx, project.ID, stage.ID, parsed.Tag); err != nil {
|
||||||
log.Printf("[webhook] deploy trigger failed: %v", err)
|
slog.Error("webhook: deploy trigger failed", "error", err)
|
||||||
http.Error(w, `{"error":"deploy trigger failed"}`, http.StatusInternalServerError)
|
respondWebhookError(w, http.StatusInternalServerError, "deploy trigger failed")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[webhook] triggered deploy for project %s stage %s tag %s", project.Name, stage.Name, parsed.Tag)
|
slog.Info("webhook: triggered deploy", "project", project.Name, "stage", stage.Name, "tag", parsed.Tag)
|
||||||
w.Header().Set("Content-Type", "application/json")
|
respondWebhookJSON(w, http.StatusOK, map[string]any{"success": true, "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
json.NewEncoder(w).Encode(map[string]any{"status": "accepted", "deploy": true, "project": project.Name, "stage": stage.Name, "tag": parsed.Tag})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureWebhookSecret checks whether a webhook secret exists in settings.
|
// 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)
|
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
|
return settings.WebhookSecret, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +258,6 @@ func RegenerateWebhookSecret(st *store.Store) (string, error) {
|
|||||||
return "", fmt.Errorf("store webhook secret: %w", err)
|
return "", fmt.Errorf("store webhook secret: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[webhook] regenerated webhook secret")
|
slog.Info("webhook: regenerated secret")
|
||||||
return settings.WebhookSecret, nil
|
return settings.WebhookSecret, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user