Files
upaas/internal/server/routes.go
clawbot f558e2cdd8
All checks were successful
Check / check (pull_request) Successful in 1m45s
feat: add observability improvements (metrics, audit log, structured logging)
- Add Prometheus metrics package (internal/metrics) with deployment,
  container health, webhook, HTTP request, and audit counters/histograms
- Add audit_log SQLite table via migration 007
- Add AuditEntry model with CRUD operations and query methods
- Add audit service (internal/service/audit) for recording user actions
- Instrument deploy service with deployment duration, count, and
  in-flight metrics; container health gauge updates on deploy completion
- Instrument webhook service with event counters by app/type/matched
- Instrument HTTP middleware with request count, duration, and response
  size metrics; also log response bytes in structured request logs
- Add audit logging to all key handler operations: login/logout, app
  CRUD, deploy, cancel, rollback, restart/stop/start, webhook receipt,
  and initial setup
- Add GET /api/audit endpoint for querying recent audit entries
- Make /metrics endpoint always available (optionally auth-protected)
- Add comprehensive tests for metrics, audit model, and audit service
- Update existing test infrastructure with metrics and audit dependencies
- Update README with Observability section documenting all metrics,
  audit log, and structured logging
2026-03-17 02:23:44 -07:00

128 lines
4.5 KiB
Go

package server
import (
"net/http"
"time"
"github.com/go-chi/chi/v5"
chimw "github.com/go-chi/chi/v5/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp"
"sneak.berlin/go/upaas/static"
)
// requestTimeout is the maximum duration for handling a request.
const requestTimeout = 60 * time.Second
// SetupRoutes configures all HTTP routes.
//
//nolint:funlen // route configuration is inherently long but straightforward
func (s *Server) SetupRoutes() {
s.router = chi.NewRouter()
// Global middleware
s.router.Use(chimw.Recoverer)
s.router.Use(chimw.RequestID)
s.router.Use(s.mw.Logging())
s.router.Use(s.mw.CORS())
s.router.Use(chimw.Timeout(requestTimeout))
s.router.Use(s.mw.SetupRequired())
// Health check (no auth required)
s.router.Get("/health", s.handlers.HandleHealthCheck())
// Static files
s.router.Handle("/s/*", http.StripPrefix(
"/s/",
http.FileServer(http.FS(static.Static)),
))
// Webhook endpoint (uses secret for auth, not session — no CSRF)
s.router.Post("/webhook/{secret}", s.handlers.HandleWebhook())
// All HTML-serving routes get CSRF protection
s.router.Group(func(r chi.Router) {
r.Use(s.mw.CSRF())
// Public routes
r.Get("/login", s.handlers.HandleLoginGET())
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleLoginPOST())
r.Get("/setup", s.handlers.HandleSetupGET())
r.Post("/setup", s.handlers.HandleSetupPOST())
// Protected routes (require session auth)
r.Group(func(r chi.Router) {
r.Use(s.mw.SessionAuth())
// Dashboard
r.Get("/", s.handlers.HandleDashboard())
// Logout
r.Post("/logout", s.handlers.HandleLogout())
// App routes
r.Get("/apps/new", s.handlers.HandleAppNew())
r.Post("/apps", s.handlers.HandleAppCreate())
r.Get("/apps/{id}", s.handlers.HandleAppDetail())
r.Get("/apps/{id}/edit", s.handlers.HandleAppEdit())
r.Post("/apps/{id}", s.handlers.HandleAppUpdate())
r.Post("/apps/{id}/delete", s.handlers.HandleAppDelete())
r.Post("/apps/{id}/deploy", s.handlers.HandleAppDeploy())
r.Post("/apps/{id}/deployments/cancel", s.handlers.HandleCancelDeploy())
r.Get("/apps/{id}/deployments", s.handlers.HandleAppDeployments())
r.Get("/apps/{id}/webhooks", s.handlers.HandleAppWebhookEvents())
r.Get("/apps/{id}/deployments/{deploymentID}/logs", s.handlers.HandleDeploymentLogsAPI())
r.Get("/apps/{id}/deployments/{deploymentID}/download", s.handlers.HandleDeploymentLogDownload())
r.Get("/apps/{id}/logs", s.handlers.HandleAppLogs())
r.Get("/apps/{id}/container-logs", s.handlers.HandleContainerLogsAPI())
r.Get("/apps/{id}/status", s.handlers.HandleAppStatusAPI())
r.Get("/apps/{id}/recent-deployments", s.handlers.HandleRecentDeploymentsAPI())
r.Post("/apps/{id}/rollback", s.handlers.HandleAppRollback())
r.Post("/apps/{id}/restart", s.handlers.HandleAppRestart())
r.Post("/apps/{id}/stop", s.handlers.HandleAppStop())
r.Post("/apps/{id}/start", s.handlers.HandleAppStart())
// Environment variables (monolithic bulk save)
r.Post("/apps/{id}/env", s.handlers.HandleEnvVarSave())
// Labels
r.Post("/apps/{id}/labels", s.handlers.HandleLabelAdd())
r.Post("/apps/{id}/labels/{labelID}/edit", s.handlers.HandleLabelEdit())
r.Post("/apps/{id}/labels/{labelID}/delete", s.handlers.HandleLabelDelete())
// Volumes
r.Post("/apps/{id}/volumes", s.handlers.HandleVolumeAdd())
r.Post("/apps/{id}/volumes/{volumeID}/edit", s.handlers.HandleVolumeEdit())
r.Post("/apps/{id}/volumes/{volumeID}/delete", s.handlers.HandleVolumeDelete())
// Ports
r.Post("/apps/{id}/ports", s.handlers.HandlePortAdd())
r.Post("/apps/{id}/ports/{portID}/delete", s.handlers.HandlePortDelete())
})
})
// API v1 routes (cookie-based session auth, no CSRF)
s.router.Route("/api/v1", func(r chi.Router) {
// Login endpoint is public (returns session cookie)
r.With(s.mw.LoginRateLimit()).Post("/login", s.handlers.HandleAPILoginPOST())
// All other API routes require session auth
r.Group(func(r chi.Router) {
r.Use(s.mw.APISessionAuth())
r.Get("/whoami", s.handlers.HandleAPIWhoAmI())
r.Get("/apps", s.handlers.HandleAPIListApps())
r.Get("/apps/{id}", s.handlers.HandleAPIGetApp())
r.Get("/apps/{id}/deployments", s.handlers.HandleAPIListDeployments())
r.Get("/audit", s.handlers.HandleAPIAuditLog())
})
})
// Metrics endpoint (always available, optionally protected with basic auth)
s.router.Group(func(r chi.Router) {
r.Use(s.mw.MetricsAuth())
r.Get("/metrics", promhttp.Handler().ServeHTTP)
})
}