CI / changes (push) Successful in 9s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 33s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 3m27s
Added PostgreSQL monitoring and maintenance capabilities to the API, including new endpoints for instance-level metrics, maintenance operations, and job scheduling. Updated the HTTP API to support PostgreSQL monitoring routes and integrated a background scheduler for metrics collection. Enhanced the CLI with database commands for maintenance tasks. Updated documentation to reflect these changes.
204 lines
6.0 KiB
Go
204 lines
6.0 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"evobgp/internal/jobs"
|
|
"evobgp/internal/pgmonitor"
|
|
)
|
|
|
|
var (
|
|
pgMaintRateMu sync.Mutex
|
|
pgMaintLastByTK = map[string]time.Time{}
|
|
)
|
|
|
|
func (s *Server) registerPostgresMaintenanceRoutes(m *http.ServeMux) {
|
|
m.HandleFunc("POST /postgres/vacuum", s.handlePostgresVacuum)
|
|
m.HandleFunc("POST /postgres/vacuum-analyze", s.handlePostgresVacuumAnalyze)
|
|
m.HandleFunc("POST /postgres/analyze", s.handlePostgresAnalyze)
|
|
m.HandleFunc("POST /postgres/reindex", s.handlePostgresReindex)
|
|
m.HandleFunc("POST /postgres/cleanup", s.handlePostgresCleanup)
|
|
m.HandleFunc("GET /postgres/maintenance/logs", s.handlePostgresMaintenanceLogs)
|
|
}
|
|
|
|
func (s *Server) requireOperatorStrict(w http.ResponseWriter, a Auth) bool {
|
|
if strings.ToLower(a.Role) != "operator" {
|
|
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (s *Server) checkPgMaintRateLimit(tenantID, kind string) bool {
|
|
key := tenantID + ":" + kind
|
|
pgMaintRateMu.Lock()
|
|
defer pgMaintRateMu.Unlock()
|
|
if t, ok := pgMaintLastByTK[key]; ok && time.Since(t) < 60*time.Second {
|
|
return false
|
|
}
|
|
pgMaintLastByTK[key] = time.Now().UTC()
|
|
return true
|
|
}
|
|
|
|
type pgMaintBody struct {
|
|
Table string `json:"table"`
|
|
DryRun bool `json:"dry_run"`
|
|
Index string `json:"index"`
|
|
Policy string `json:"policy"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
|
|
func (s *Server) decodePgMaintBody(r *http.Request) (pgMaintBody, bool) {
|
|
var body pgMaintBody
|
|
if r.Body == nil || r.ContentLength == 0 {
|
|
return body, true
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
|
|
return body, false
|
|
}
|
|
return body, true
|
|
}
|
|
|
|
func (s *Server) enqueuePostgresMaint(w http.ResponseWriter, r *http.Request, a Auth, kind string, meta map[string]any) {
|
|
if !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) {
|
|
return
|
|
}
|
|
if !s.checkPgMaintRateLimit(a.TenantID, kind) {
|
|
writeProblem(w, http.StatusTooManyRequests, "Too Many Requests", "wait before repeating this maintenance operation")
|
|
return
|
|
}
|
|
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
|
var idemPtr *string
|
|
if idem != "" {
|
|
idemPtr = &idem
|
|
}
|
|
meta["actor_prefix"] = actorPrefix(a)
|
|
j, _, err := s.jobs.Enqueue(a.TenantID, kind, idemPtr, nil, meta)
|
|
if err != nil {
|
|
writeInternalError(w, "postgres_maint_enqueue", err)
|
|
return
|
|
}
|
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
|
snap := j.Snapshot()
|
|
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
|
|
}
|
|
|
|
func (s *Server) handlePostgresVacuum(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
|
return
|
|
}
|
|
body, ok2 := s.decodePgMaintBody(r)
|
|
if !ok2 {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
|
return
|
|
}
|
|
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresVacuum, map[string]any{
|
|
"table": body.Table, "dry_run": body.DryRun, "job_title": "PostgreSQL VACUUM",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostgresVacuumAnalyze(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
|
return
|
|
}
|
|
body, ok2 := s.decodePgMaintBody(r)
|
|
if !ok2 {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
|
return
|
|
}
|
|
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresVacuumAnalyze, map[string]any{
|
|
"table": body.Table, "dry_run": body.DryRun, "job_title": "PostgreSQL VACUUM ANALYZE",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostgresAnalyze(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
|
return
|
|
}
|
|
body, ok2 := s.decodePgMaintBody(r)
|
|
if !ok2 {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
|
return
|
|
}
|
|
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresAnalyze, map[string]any{
|
|
"table": body.Table, "dry_run": body.DryRun, "job_title": "PostgreSQL ANALYZE",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostgresReindex(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
|
return
|
|
}
|
|
body, ok2 := s.decodePgMaintBody(r)
|
|
if !ok2 {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
|
return
|
|
}
|
|
table := body.Table
|
|
if table == "" {
|
|
table = body.Index
|
|
}
|
|
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresReindex, map[string]any{
|
|
"table": table, "dry_run": body.DryRun, "job_title": "PostgreSQL REINDEX",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostgresCleanup(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok {
|
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
|
return
|
|
}
|
|
body, ok2 := s.decodePgMaintBody(r)
|
|
if !ok2 {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail)
|
|
return
|
|
}
|
|
if strings.TrimSpace(body.Policy) == "" {
|
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "policy is required")
|
|
return
|
|
}
|
|
s.enqueuePostgresMaint(w, r, a, jobs.KindPostgresCleanup, map[string]any{
|
|
"policy": body.Policy, "dry_run": body.DryRun, "limit": body.Limit,
|
|
"job_title": "PostgreSQL cleanup",
|
|
})
|
|
}
|
|
|
|
func (s *Server) handlePostgresMaintenanceLogs(w http.ResponseWriter, r *http.Request) {
|
|
a, ok := authFromContext(r.Context())
|
|
if !ok || !s.requireAtLeast(w, a, "viewer") || !s.requirePostgres(w) {
|
|
return
|
|
}
|
|
cursor := r.URL.Query().Get("cursor")
|
|
limit := parseLimitQuery(r, 20, 100)
|
|
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
|
defer cancel()
|
|
items, next, hasMore, err := pgmonitor.ListMaintenanceLogs(ctx, s.pgMonitor.Pool(), cursor, limit)
|
|
if err != nil {
|
|
writeInternalError(w, "postgres_maint_logs", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": next, "has_more": hasMore})
|
|
}
|
|
|
|
func actorPrefix(a Auth) string {
|
|
if len(a.Token) >= 8 {
|
|
return a.Token[:8]
|
|
}
|
|
return a.Role
|
|
}
|