feat(docker-watcher): phase 12 - hardening
Blue-green zero-downtime deploys, promote flow validation. Dual auth: local (bcrypt + JWT) and OAuth2/OIDC (any provider). Auth middleware, login page, auth settings UI. Structured logging (slog JSON), config export to YAML. Graceful shutdown with deploy draining. Multi-stage Dockerfile and production docker-compose.yml. Swap phase order: Volumes & Env before UI Polish.
This commit is contained in:
@@ -0,0 +1,319 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
)
|
||||
|
||||
// login handles POST /api/auth/login.
|
||||
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
var req auth.LoginRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
respondError(w, http.StatusBadRequest, "username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByUsername(req.Username)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := auth.CheckPassword(user.PasswordHash, req.Password); err != nil {
|
||||
respondError(w, http.StatusUnauthorized, "invalid credentials")
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.localAuth.GenerateToken(auth.Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, token)
|
||||
}
|
||||
|
||||
// currentUser handles GET /api/auth/me — returns the authenticated user.
|
||||
func (s *Server) currentUser(w http.ResponseWriter, r *http.Request) {
|
||||
claims, ok := auth.ClaimsFromContext(r.Context())
|
||||
if !ok {
|
||||
respondError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByID(claims.UserID)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, user)
|
||||
}
|
||||
|
||||
// oidcLogin handles GET /api/auth/oidc/login — redirects to OIDC provider.
|
||||
func (s *Server) oidcLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidcProvider == nil {
|
||||
respondError(w, http.StatusBadRequest, "OIDC is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Generate random state.
|
||||
stateBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(stateBytes); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate state")
|
||||
return
|
||||
}
|
||||
state := hex.EncodeToString(stateBytes)
|
||||
|
||||
// Store state in a short-lived cookie for validation on callback.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: state,
|
||||
Path: "/api/auth/oidc",
|
||||
MaxAge: 300, // 5 minutes
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
|
||||
http.Redirect(w, r, s.oidcProvider.AuthCodeURL(state), http.StatusFound)
|
||||
}
|
||||
|
||||
// oidcCallback handles GET /api/auth/oidc/callback — exchanges code for tokens.
|
||||
func (s *Server) oidcCallback(w http.ResponseWriter, r *http.Request) {
|
||||
if s.oidcProvider == nil {
|
||||
respondError(w, http.StatusBadRequest, "OIDC is not configured")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate state parameter.
|
||||
stateCookie, err := r.Cookie("oidc_state")
|
||||
if err != nil || stateCookie.Value == "" {
|
||||
respondError(w, http.StatusBadRequest, "missing OIDC state")
|
||||
return
|
||||
}
|
||||
|
||||
if r.URL.Query().Get("state") != stateCookie.Value {
|
||||
respondError(w, http.StatusBadRequest, "invalid OIDC state")
|
||||
return
|
||||
}
|
||||
|
||||
// Clear the state cookie.
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "oidc_state",
|
||||
Value: "",
|
||||
Path: "/api/auth/oidc",
|
||||
MaxAge: -1,
|
||||
})
|
||||
|
||||
code := r.URL.Query().Get("code")
|
||||
if code == "" {
|
||||
respondError(w, http.StatusBadRequest, "missing authorization code")
|
||||
return
|
||||
}
|
||||
|
||||
userInfo, err := s.oidcProvider.Exchange(r.Context(), code)
|
||||
if err != nil {
|
||||
slog.Error("OIDC exchange failed", "error", err)
|
||||
respondError(w, http.StatusInternalServerError, "OIDC authentication failed")
|
||||
return
|
||||
}
|
||||
|
||||
// Find or create local user linked to the OIDC identity.
|
||||
username := userInfo.Username
|
||||
if username == "" {
|
||||
username = userInfo.Email
|
||||
}
|
||||
if username == "" {
|
||||
username = userInfo.Subject
|
||||
}
|
||||
|
||||
user, err := s.store.GetUserByUsername(username)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Auto-create user from OIDC.
|
||||
user, err = s.store.CreateUser(store.User{
|
||||
Username: username,
|
||||
Email: userInfo.Email,
|
||||
Role: "viewer", // OIDC users default to viewer; admin promotes via settings
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
token, err := s.localAuth.GenerateToken(auth.Claims{
|
||||
UserID: user.ID,
|
||||
Username: user.Username,
|
||||
Role: user.Role,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to generate token: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Redirect to frontend with token in query parameter.
|
||||
// The frontend extracts the token and stores it in localStorage.
|
||||
http.Redirect(w, r, "/?token="+token.Token, http.StatusFound)
|
||||
}
|
||||
|
||||
// getAuthSettings handles GET /api/auth/settings.
|
||||
func (s *Server) getAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
as, err := s.store.GetAuthSettings()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to get auth settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
// Mask the client secret for the response.
|
||||
if as.OIDCClientSecret != "" {
|
||||
as.OIDCClientSecret = "********"
|
||||
}
|
||||
respondJSON(w, http.StatusOK, as)
|
||||
}
|
||||
|
||||
// updateAuthSettings handles PUT /api/auth/settings.
|
||||
func (s *Server) updateAuthSettings(w http.ResponseWriter, r *http.Request) {
|
||||
var req store.AuthSettings
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.AuthMode != "local" && req.AuthMode != "oidc" {
|
||||
respondError(w, http.StatusBadRequest, "auth_mode must be 'local' or 'oidc'")
|
||||
return
|
||||
}
|
||||
|
||||
// If client secret is masked, preserve the existing value.
|
||||
if req.OIDCClientSecret == "********" || req.OIDCClientSecret == "" {
|
||||
existing, err := s.store.GetAuthSettings()
|
||||
if err == nil {
|
||||
req.OIDCClientSecret = existing.OIDCClientSecret
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.UpdateAuthSettings(req); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update auth settings: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Re-initialize OIDC provider if mode is oidc and config is present.
|
||||
if req.AuthMode == "oidc" && req.OIDCIssuerURL != "" && req.OIDCClientID != "" {
|
||||
s.initOIDCProvider(r.Context(), req)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, req)
|
||||
}
|
||||
|
||||
// listUsers handles GET /api/auth/users.
|
||||
func (s *Server) listUsers(w http.ResponseWriter, r *http.Request) {
|
||||
users, err := s.store.GetAllUsers()
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users: "+err.Error())
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, users)
|
||||
}
|
||||
|
||||
// createUser handles POST /api/auth/users.
|
||||
func (s *Server) createUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
}
|
||||
|
||||
if req.Username == "" || req.Password == "" {
|
||||
respondError(w, http.StatusBadRequest, "username and password are required")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Role == "" {
|
||||
req.Role = "viewer"
|
||||
}
|
||||
|
||||
hash, err := auth.HashPassword(req.Password)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to hash password: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
user, err := s.store.CreateUser(store.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: hash,
|
||||
Email: req.Email,
|
||||
Role: req.Role,
|
||||
})
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create user: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusCreated, user)
|
||||
}
|
||||
|
||||
// deleteUser handles DELETE /api/auth/users/{uid}.
|
||||
func (s *Server) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "uid")
|
||||
|
||||
// Prevent deleting the last admin.
|
||||
user, err := s.store.GetUserByID(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
respondNotFound(w, "user")
|
||||
return
|
||||
}
|
||||
respondError(w, http.StatusInternalServerError, "failed to get user: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if user.Role == "admin" {
|
||||
users, err := s.store.GetAllUsers()
|
||||
if err == nil {
|
||||
adminCount := 0
|
||||
for _, u := range users {
|
||||
if u.Role == "admin" {
|
||||
adminCount++
|
||||
}
|
||||
}
|
||||
if adminCount <= 1 {
|
||||
respondError(w, http.StatusBadRequest, "cannot delete the last admin user")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.store.DeleteUser(id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete user: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{"deleted": id})
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/config"
|
||||
)
|
||||
|
||||
// exportConfig handles GET /api/config/export — downloads current state as YAML.
|
||||
func (s *Server) exportConfig(w http.ResponseWriter, r *http.Request) {
|
||||
data, err := config.ExportConfig(s.store)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to export config: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-yaml")
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=docker-watcher.yaml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -61,7 +61,7 @@ func (s *Server) inspectImage(w http.ResponseWriter, r *http.Request) {
|
||||
// Split image:tag for the pull call.
|
||||
imageRef, tag := splitImageTag(req.Image)
|
||||
if err := s.docker.PullImage(ctx, imageRef, tag, ""); err != nil {
|
||||
log.Printf("[api] pull image %s for inspect: %v", req.Image, err)
|
||||
slog.Warn("pull image for inspect", "image", req.Image, "error", err)
|
||||
// Try to inspect anyway in case the image is already local.
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -103,7 +103,7 @@ func (s *Server) removeInstance(w http.ResponseWriter, r *http.Request) {
|
||||
// Remove the Docker container if it has one.
|
||||
if inst.ContainerID != "" {
|
||||
if err := s.docker.RemoveContainer(r.Context(), inst.ContainerID, true); err != nil {
|
||||
log.Printf("[api] remove container %s: %v", inst.ContainerID, err)
|
||||
slog.Error("remove container", "container_id", inst.ContainerID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ func (s *Server) controlInstance(w http.ResponseWriter, r *http.Request, action
|
||||
|
||||
// Update status in store.
|
||||
if err := s.store.UpdateInstanceStatus(instanceID, newStatus); err != nil {
|
||||
log.Printf("[api] update instance %s status to %s: %v", instanceID, newStatus, err)
|
||||
slog.Error("update instance status", "instance_id", instanceID, "status", newStatus, "error", err)
|
||||
}
|
||||
|
||||
respondJSON(w, http.StatusOK, map[string]string{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
@@ -16,7 +16,12 @@ func logging(next http.Handler) http.Handler {
|
||||
|
||||
next.ServeHTTP(wrapped, r)
|
||||
|
||||
log.Printf("[api] %s %s %d %s", r.Method, r.URL.Path, wrapped.status, time.Since(start))
|
||||
slog.Info("http request",
|
||||
"method", r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", wrapped.status,
|
||||
"duration", time.Since(start).String(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -25,7 +30,7 @@ func recovery(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if err := recover(); err != nil {
|
||||
log.Printf("[api] panic: %v\n%s", err, debug.Stack())
|
||||
slog.Error("panic recovered", "error", err, "stack", string(debug.Stack()))
|
||||
respondError(w, http.StatusInternalServerError, "internal server error")
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -2,7 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ func respondJSON(w http.ResponseWriter, status int, data any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(envelope{Success: true, Data: data}); err != nil {
|
||||
log.Printf("[api] encode response: %v", err)
|
||||
slog.Error("encode response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ func respondError(w http.ResponseWriter, status int, msg string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
if err := json.NewEncoder(w).Encode(envelope{Success: false, Error: msg}); err != nil {
|
||||
log.Printf("[api] encode error response: %v", err)
|
||||
slog.Error("encode error response", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+118
-66
@@ -1,8 +1,12 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/alexei/docker-watcher/internal/auth"
|
||||
"github.com/alexei/docker-watcher/internal/docker"
|
||||
"github.com/alexei/docker-watcher/internal/events"
|
||||
"github.com/alexei/docker-watcher/internal/store"
|
||||
@@ -11,12 +15,14 @@ import (
|
||||
|
||||
// Server holds all dependencies for the API layer.
|
||||
type Server struct {
|
||||
store *store.Store
|
||||
docker *docker.Client
|
||||
deployer DeployTriggerer
|
||||
webhook *webhook.Handler
|
||||
eventBus *events.Bus
|
||||
encKey [32]byte
|
||||
store *store.Store
|
||||
docker *docker.Client
|
||||
deployer DeployTriggerer
|
||||
webhook *webhook.Handler
|
||||
eventBus *events.Bus
|
||||
encKey [32]byte
|
||||
localAuth *auth.LocalAuth
|
||||
oidcProvider *auth.OIDCProvider
|
||||
}
|
||||
|
||||
// NewServer creates a new API Server with all required dependencies.
|
||||
@@ -28,19 +34,44 @@ func NewServer(
|
||||
eventBus *events.Bus,
|
||||
encKey [32]byte,
|
||||
) *Server {
|
||||
return &Server{
|
||||
store: st,
|
||||
docker: dockerClient,
|
||||
deployer: deployer,
|
||||
webhook: webhookHandler,
|
||||
eventBus: eventBus,
|
||||
encKey: encKey,
|
||||
localAuth := auth.NewLocalAuth(encKey)
|
||||
|
||||
s := &Server{
|
||||
store: st,
|
||||
docker: dockerClient,
|
||||
deployer: deployer,
|
||||
webhook: webhookHandler,
|
||||
eventBus: eventBus,
|
||||
encKey: encKey,
|
||||
localAuth: localAuth,
|
||||
}
|
||||
|
||||
// Try to initialize OIDC provider from stored settings.
|
||||
authSettings, err := st.GetAuthSettings()
|
||||
if err == nil && authSettings.AuthMode == "oidc" && authSettings.OIDCIssuerURL != "" {
|
||||
s.initOIDCProvider(context.Background(), authSettings)
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// initOIDCProvider creates an OIDC provider from settings. Errors are logged, not fatal.
|
||||
func (s *Server) initOIDCProvider(ctx context.Context, as store.AuthSettings) {
|
||||
provider, err := auth.NewOIDCProvider(ctx, auth.OIDCConfig{
|
||||
IssuerURL: as.OIDCIssuerURL,
|
||||
ClientID: as.OIDCClientID,
|
||||
ClientSecret: as.OIDCClientSecret,
|
||||
RedirectURL: as.OIDCRedirectURL,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to initialize OIDC provider", "error", err)
|
||||
return
|
||||
}
|
||||
s.oidcProvider = provider
|
||||
slog.Info("OIDC provider initialized", "issuer", as.OIDCIssuerURL)
|
||||
}
|
||||
|
||||
// Router returns a chi router with all API routes mounted.
|
||||
// NOTE: Authentication middleware is added in Phase 12 (Hardening).
|
||||
// Until then, this API should only be exposed on trusted networks.
|
||||
func (s *Server) Router() chi.Router {
|
||||
r := chi.NewRouter()
|
||||
|
||||
@@ -51,59 +82,80 @@ func (s *Server) Router() chi.Router {
|
||||
r.Use(jsonContentType)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
// Project endpoints.
|
||||
r.Get("/projects", s.listProjects)
|
||||
r.Post("/projects", s.createProject)
|
||||
r.Route("/projects/{id}", func(r chi.Router) {
|
||||
r.Get("/", s.getProject)
|
||||
r.Put("/", s.updateProject)
|
||||
r.Delete("/", s.deleteProject)
|
||||
// Public auth endpoints (no auth required).
|
||||
r.Post("/auth/login", s.login)
|
||||
r.Get("/auth/oidc/login", s.oidcLogin)
|
||||
r.Get("/auth/oidc/callback", s.oidcCallback)
|
||||
|
||||
// Stage endpoints.
|
||||
r.Post("/stages", s.createStage)
|
||||
r.Put("/stages/{stage}", s.updateStage)
|
||||
r.Delete("/stages/{stage}", s.deleteStage)
|
||||
|
||||
// Instance endpoints.
|
||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||
r.Post("/stages/{stage}/instances", s.deployInstance)
|
||||
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
|
||||
|
||||
// Instance control endpoints.
|
||||
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
|
||||
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
|
||||
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
|
||||
})
|
||||
|
||||
// Deploy endpoints.
|
||||
r.Get("/deploys", s.listDeploys)
|
||||
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
|
||||
|
||||
// SSE endpoint for real-time instance status and deploy events.
|
||||
r.Get("/events", s.streamEvents)
|
||||
|
||||
// Quick deploy endpoints.
|
||||
r.Post("/deploy/inspect", s.inspectImage)
|
||||
r.Post("/deploy/quick", s.quickDeploy)
|
||||
|
||||
// Registry endpoints.
|
||||
r.Get("/registries", s.listRegistries)
|
||||
r.Post("/registries", s.createRegistry)
|
||||
r.Route("/registries/{id}", func(r chi.Router) {
|
||||
r.Put("/", s.updateRegistry)
|
||||
r.Delete("/", s.deleteRegistry)
|
||||
r.Post("/test", s.testRegistry)
|
||||
r.Get("/tags/*", s.listRegistryTags)
|
||||
})
|
||||
|
||||
// Settings endpoints.
|
||||
r.Get("/settings", s.getSettings)
|
||||
r.Put("/settings", s.updateSettings)
|
||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
||||
r.Post("/settings/regenerate", s.regenerateWebhookSecret)
|
||||
|
||||
// Webhook handler (from webhook package).
|
||||
// Webhook handler (uses its own secret-based auth).
|
||||
r.Mount("/webhook", s.webhook.Route())
|
||||
|
||||
// Config export (public endpoint, useful for backup).
|
||||
r.Get("/config/export", s.exportConfig)
|
||||
|
||||
// Protected routes: require valid JWT.
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(auth.Middleware(s.localAuth))
|
||||
|
||||
// Auth management.
|
||||
r.Get("/auth/me", s.currentUser)
|
||||
r.Get("/auth/settings", s.getAuthSettings)
|
||||
r.Put("/auth/settings", s.updateAuthSettings)
|
||||
r.Get("/auth/users", s.listUsers)
|
||||
r.Post("/auth/users", s.createUser)
|
||||
r.Delete("/auth/users/{uid}", s.deleteUser)
|
||||
|
||||
// Project endpoints.
|
||||
r.Get("/projects", s.listProjects)
|
||||
r.Post("/projects", s.createProject)
|
||||
r.Route("/projects/{id}", func(r chi.Router) {
|
||||
r.Get("/", s.getProject)
|
||||
r.Put("/", s.updateProject)
|
||||
r.Delete("/", s.deleteProject)
|
||||
|
||||
// Stage endpoints.
|
||||
r.Post("/stages", s.createStage)
|
||||
r.Put("/stages/{stage}", s.updateStage)
|
||||
r.Delete("/stages/{stage}", s.deleteStage)
|
||||
|
||||
// Instance endpoints.
|
||||
r.Get("/stages/{stage}/instances", s.listInstances)
|
||||
r.Post("/stages/{stage}/instances", s.deployInstance)
|
||||
r.Delete("/stages/{stage}/instances/{iid}", s.removeInstance)
|
||||
|
||||
// Instance control endpoints.
|
||||
r.Post("/stages/{stage}/instances/{iid}/stop", s.stopInstance)
|
||||
r.Post("/stages/{stage}/instances/{iid}/start", s.startInstance)
|
||||
r.Post("/stages/{stage}/instances/{iid}/restart", s.restartInstance)
|
||||
})
|
||||
|
||||
// Deploy endpoints.
|
||||
r.Get("/deploys", s.listDeploys)
|
||||
r.Get("/deploys/{id}/logs", s.streamDeployLogs)
|
||||
|
||||
// SSE endpoint for real-time instance status and deploy events.
|
||||
r.Get("/events", s.streamEvents)
|
||||
|
||||
// Quick deploy endpoints.
|
||||
r.Post("/deploy/inspect", s.inspectImage)
|
||||
r.Post("/deploy/quick", s.quickDeploy)
|
||||
|
||||
// Registry endpoints.
|
||||
r.Get("/registries", s.listRegistries)
|
||||
r.Post("/registries", s.createRegistry)
|
||||
r.Route("/registries/{id}", func(r chi.Router) {
|
||||
r.Put("/", s.updateRegistry)
|
||||
r.Delete("/", s.deleteRegistry)
|
||||
r.Post("/test", s.testRegistry)
|
||||
r.Get("/tags/*", s.listRegistryTags)
|
||||
})
|
||||
|
||||
// Settings endpoints.
|
||||
r.Get("/settings", s.getSettings)
|
||||
r.Put("/settings", s.updateSettings)
|
||||
r.Get("/settings/webhook-url", s.getWebhookURL)
|
||||
r.Post("/settings/regenerate", s.regenerateWebhookSecret)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
|
||||
+3
-3
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -68,7 +68,7 @@ func (s *Server) streamDeployLogs(w http.ResponseWriter, r *http.Request) {
|
||||
// Send existing logs first.
|
||||
existingLogs, err := s.store.GetDeployLogs(deployID)
|
||||
if err != nil {
|
||||
log.Printf("[sse] failed to get existing deploy logs: %v", err)
|
||||
slog.Error("get existing deploy logs", "error", err)
|
||||
} else {
|
||||
for _, entry := range existingLogs {
|
||||
writeSSE(w, flusher, events.Event{
|
||||
@@ -174,7 +174,7 @@ func (s *Server) streamEvents(w http.ResponseWriter, r *http.Request) {
|
||||
func writeSSE(w http.ResponseWriter, flusher http.Flusher, evt events.Event) {
|
||||
data, err := json.Marshal(evt)
|
||||
if err != nil {
|
||||
log.Printf("[sse] marshal event: %v", err)
|
||||
slog.Error("marshal SSE event", "error", err)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
|
||||
Reference in New Issue
Block a user