package httpapi import ( "encoding/json" "net/http" "strings" "evobgp/internal/jobs" "evobgp/internal/observability" "evobgp/internal/store" ) func (s *Server) registerMaintenanceRoutes(m *http.ServeMux) { m.HandleFunc("GET /maintenance/policies", s.handleListMaintenancePolicies) m.HandleFunc("POST /maintenance/policies", s.handleCreateMaintenancePolicy) m.HandleFunc("GET /maintenance/policies/{id}", s.handleGetMaintenancePolicy) m.HandleFunc("PATCH /maintenance/policies/{id}", s.handlePatchMaintenancePolicy) m.HandleFunc("DELETE /maintenance/policies/{id}", s.handleDeleteMaintenancePolicy) m.HandleFunc("GET /maintenance/policies/{id}/hints", s.handleMaintenancePolicyHints) m.HandleFunc("GET /maintenance/config-audit", s.handleListMaintenanceConfigAudit) m.HandleFunc("POST /maintenance/run", s.handleMaintenanceRun) m.HandleFunc("POST /maintenance/dry-run", s.handleMaintenanceDryRun) } func maintenancePolicyJSON(p *store.MaintenancePolicy) map[string]any { if p == nil { return map[string]any{} } out := map[string]any{ "id": p.ID, "name": p.Name, "table_name": p.TableName, "condition": p.Condition, "vacuum_strategy": p.VacuumStrategy, "schedule": p.Schedule, "enabled": p.Enabled, "dry_run_enabled": p.DryRunEnabled, } if p.RetentionPeriodSec != nil { out["retention_period_sec"] = *p.RetentionPeriodSec } if p.MaxRows != nil { out["max_rows"] = *p.MaxRows } if p.LastRunAt != nil { out["last_run_at"] = p.LastRunAt.UTC().Format("2006-01-02T15:04:05Z") } if p.LastStatus != "" { out["last_status"] = p.LastStatus } if p.LastError != "" { out["last_error"] = p.LastError } if !p.CreatedAt.IsZero() { out["created_at"] = p.CreatedAt.UTC().Format("2006-01-02T15:04:05Z") } if !p.UpdatedAt.IsZero() { out["updated_at"] = p.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z") } return out } func (s *Server) handleListMaintenancePolicies(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) { return } cursor := r.URL.Query().Get("cursor") limit := parseLimitQuery(r, 20, 100) items, next, hasMore, err := s.store.ListMaintenancePolicies(cursor, limit) if err != nil { writeInternalError(w, "maintenance_policies_list", err) return } out := make([]map[string]any, 0, len(items)) for _, p := range items { out = append(out, maintenancePolicyJSON(p)) } writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore}) } func (s *Server) handleGetMaintenancePolicy(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) { return } p, err := s.store.GetMaintenancePolicy(r.PathValue("id")) if err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, maintenancePolicyJSON(p)) } func (s *Server) handleCreateMaintenancePolicy(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) { return } var body store.MaintenancePolicy if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail) return } p, err := s.store.CreateMaintenancePolicy(&body) if err != nil { writeStoreErr(w, err) return } _ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), p.ID, "create", nil, maintenancePolicyJSON(p)) observability.IncMaintenanceConfigChange("create") s.reloadMaintenanceConfig(r) writeJSON(w, http.StatusCreated, maintenancePolicyJSON(p)) } func (s *Server) handlePatchMaintenancePolicy(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) { return } id := r.PathValue("id") before, err := s.store.GetMaintenancePolicy(id) if err != nil { writeStoreErr(w, err) return } var patch store.MaintenancePolicyPatch if err := json.NewDecoder(r.Body).Decode(&patch); err != nil { writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail) return } updated, err := s.store.UpdateMaintenancePolicy(id, &patch) if err != nil { writeStoreErr(w, err) return } _ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), id, "update", maintenancePolicyJSON(before), maintenancePolicyJSON(updated)) observability.IncMaintenanceConfigChange("update") s.reloadMaintenanceConfig(r) writeJSON(w, http.StatusOK, maintenancePolicyJSON(updated)) } func (s *Server) handleDeleteMaintenancePolicy(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) { return } id := r.PathValue("id") before, err := s.store.GetMaintenancePolicy(id) if err != nil { writeStoreErr(w, err) return } if err := s.store.DeleteMaintenancePolicy(id); err != nil { writeStoreErr(w, err) return } _ = s.store.AppendMaintenancePolicyConfigAudit(actorPrefix(a), id, "delete", maintenancePolicyJSON(before), nil) observability.IncMaintenanceConfigChange("delete") s.reloadMaintenanceConfig(r) w.WriteHeader(http.StatusNoContent) } func (s *Server) handleMaintenancePolicyHints(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) { return } if s.maintStats == nil { writeProblem(w, http.StatusServiceUnavailable, "Unavailable", "postgresql backend required") return } p, err := s.store.GetMaintenancePolicy(r.PathValue("id")) if err != nil { writeStoreErr(w, err) return } hints, err := s.maintStats.Hints(r.Context(), p.TableName) if err != nil { writeInternalError(w, "maintenance_policy_hints", err) return } writeJSON(w, http.StatusOK, hints) } func (s *Server) handleListMaintenanceConfigAudit(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePerm(w, a, "bgp:monitoring:read") || !s.requirePostgres(w) { return } cursor := r.URL.Query().Get("cursor") limit := parseLimitQuery(r, 20, 100) items, next, hasMore, err := s.store.ListMaintenancePolicyConfigAudit(cursor, limit) if err != nil { writeInternalError(w, "maintenance_config_audit", err) return } out := make([]map[string]any, 0, len(items)) for _, row := range items { out = append(out, map[string]any{ "id": row.ID, "policy_id": row.PolicyID, "actor_prefix": row.ActorPrefix, "action": row.Action, "before": row.Before, "after": row.After, "created_at": row.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), }) } writeJSON(w, http.StatusOK, map[string]any{"items": out, "next_cursor": next, "has_more": hasMore}) } type maintenanceRunBody struct { PolicyID string `json:"policy_id"` } func (s *Server) handleMaintenanceRun(w http.ResponseWriter, r *http.Request) { s.enqueueMaintenancePolicy(w, r, false) } func (s *Server) handleMaintenanceDryRun(w http.ResponseWriter, r *http.Request) { s.enqueueMaintenancePolicy(w, r, true) } func (s *Server) enqueueMaintenancePolicy(w http.ResponseWriter, r *http.Request, dryRun bool) { a, ok := authFromContext(r.Context()) if !ok || !s.requirePostgres(w) || !s.requireOperatorStrict(w, a) { return } var body maintenanceRunBody if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeProblem(w, http.StatusBadRequest, "Bad Request", invalidInputDetail) return } policyID := strings.TrimSpace(body.PolicyID) if policyID == "" { writeProblem(w, http.StatusBadRequest, "Bad Request", "policy_id is required") return } if _, err := s.store.GetMaintenancePolicy(policyID); err != nil { writeStoreErr(w, err) return } kind := "maintenance_policy_run" if !s.checkPgMaintRateLimit(a.TenantID, kind+":"+policyID) { 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 } title := "Maintenance policy run" if dryRun { title = "Maintenance policy dry-run" } j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindMaintenancePolicyRun, idemPtr, nil, map[string]any{ "policy_id": policyID, "dry_run": dryRun, "actor_prefix": actorPrefix(a), "job_title": title, }) if err != nil { writeInternalError(w, "maintenance_policy_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) reloadMaintenanceConfig(r *http.Request) { if s.maintConfig != nil { _ = s.maintConfig.Reload(r.Context()) } }