feat(api): add /v1/maintenance policies and run endpoints
OpenAPI, httpapi CRUD/run/dry-run, job maintenance_policy_run и audit с policy_id. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+295
-1
@@ -51,6 +51,8 @@ tags:
|
|||||||
description: Сессия текущего API-ключа (tenant и роль).
|
description: Сессия текущего API-ключа (tenant и роль).
|
||||||
- name: Monitoring
|
- name: Monitoring
|
||||||
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
|
description: Наблюдаемость PostgreSQL и корреляция (instance-level, viewer+). Maintenance — operator.
|
||||||
|
- name: Maintenance
|
||||||
|
description: Политики обслуживания PostgreSQL (instance-scoped). CRUD и запуск — operator.
|
||||||
|
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
@@ -966,10 +968,98 @@ components:
|
|||||||
default: false
|
default: false
|
||||||
policy:
|
policy:
|
||||||
type: string
|
type: string
|
||||||
description: job_audit_retention | asn_cache_retention
|
description: Deprecated; use maintenance policies API.
|
||||||
limit:
|
limit:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
||||||
|
MaintenancePolicy:
|
||||||
|
type: object
|
||||||
|
required: [name, table_name, schedule, vacuum_strategy]
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
table_name:
|
||||||
|
type: string
|
||||||
|
condition:
|
||||||
|
type: string
|
||||||
|
default: "true"
|
||||||
|
retention_period_sec:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
max_rows:
|
||||||
|
type: integer
|
||||||
|
minimum: 1
|
||||||
|
maximum: 100000
|
||||||
|
vacuum_strategy:
|
||||||
|
type: string
|
||||||
|
enum: [none, vacuum, analyze, vacuum_analyze, reindex]
|
||||||
|
schedule:
|
||||||
|
type: string
|
||||||
|
description: Cron expression (5-field, UTC).
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
dry_run_enabled:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
last_run_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
last_status:
|
||||||
|
type: string
|
||||||
|
last_error:
|
||||||
|
type: string
|
||||||
|
created_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
updated_at:
|
||||||
|
type: string
|
||||||
|
format: date-time
|
||||||
|
|
||||||
|
MaintenancePolicyPatch:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
table_name:
|
||||||
|
type: string
|
||||||
|
condition:
|
||||||
|
type: string
|
||||||
|
retention_period_sec:
|
||||||
|
type: integer
|
||||||
|
max_rows:
|
||||||
|
type: integer
|
||||||
|
vacuum_strategy:
|
||||||
|
type: string
|
||||||
|
enum: [none, vacuum, analyze, vacuum_analyze, reindex]
|
||||||
|
schedule:
|
||||||
|
type: string
|
||||||
|
enabled:
|
||||||
|
type: boolean
|
||||||
|
dry_run_enabled:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
|
MaintenanceRunBody:
|
||||||
|
type: object
|
||||||
|
required: [policy_id]
|
||||||
|
properties:
|
||||||
|
policy_id:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
|
||||||
|
MaintenancePolicyList:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicy"
|
||||||
|
next_cursor:
|
||||||
|
type: string
|
||||||
|
has_more:
|
||||||
|
type: boolean
|
||||||
|
|
||||||
BirdLocalStatus:
|
BirdLocalStatus:
|
||||||
type: object
|
type: object
|
||||||
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
description: Статус локального BIRD на хосте API (GET /v1/bird/status).
|
||||||
@@ -3467,6 +3557,210 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/maintenance/policies:
|
||||||
|
get:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: List maintenance policies
|
||||||
|
operationId: listMaintenancePolicies
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicyList"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
post:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Create maintenance policy
|
||||||
|
operationId: createMaintenancePolicy
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicy"
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: Создано.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicy"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/maintenance/policies/{id}:
|
||||||
|
get:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Get maintenance policy
|
||||||
|
operationId: getMaintenancePolicy
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicy"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
patch:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Update maintenance policy
|
||||||
|
operationId: patchMaintenancePolicy
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicyPatch"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenancePolicy"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
delete:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Delete maintenance policy
|
||||||
|
operationId: deleteMaintenancePolicy
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Удалено.
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/maintenance/policies/{id}/hints:
|
||||||
|
get:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: PostgreSQL hints for policy table
|
||||||
|
operationId: getMaintenancePolicyHints
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: id
|
||||||
|
in: path
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/maintenance/config-audit:
|
||||||
|
get:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Maintenance policy configuration audit log
|
||||||
|
operationId: listMaintenanceConfigAudit
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/Cursor"
|
||||||
|
- $ref: "#/components/parameters/Limit"
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Успешно.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: object
|
||||||
|
additionalProperties: true
|
||||||
|
next_cursor:
|
||||||
|
type: string
|
||||||
|
has_more:
|
||||||
|
type: boolean
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/maintenance/run:
|
||||||
|
post:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Run maintenance policy (async job)
|
||||||
|
operationId: postMaintenanceRun
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenanceRunBody"
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Задача поставлена.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AsyncJobAccepted"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/maintenance/dry-run:
|
||||||
|
post:
|
||||||
|
tags: [Maintenance]
|
||||||
|
summary: Dry-run maintenance policy (async job)
|
||||||
|
operationId: postMaintenanceDryRun
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- $ref: "#/components/parameters/IdempotencyKey"
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/MaintenanceRunBody"
|
||||||
|
responses:
|
||||||
|
"202":
|
||||||
|
description: Задача поставлена.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/AsyncJobAccepted"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/settings:
|
/v1/settings:
|
||||||
get:
|
get:
|
||||||
tags: [Settings]
|
tags: [Settings]
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
s.registerCRUDRoutes(m)
|
s.registerCRUDRoutes(m)
|
||||||
s.registerPostgresMonitoringRoutes(m)
|
s.registerPostgresMonitoringRoutes(m)
|
||||||
s.registerPostgresMaintenanceRoutes(m)
|
s.registerPostgresMaintenanceRoutes(m)
|
||||||
|
s.registerMaintenanceRoutes(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,270 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/jobs"
|
||||||
|
"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.requireAtLeast(w, a, "viewer") || !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.requireAtLeast(w, a, "viewer") || !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))
|
||||||
|
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))
|
||||||
|
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)
|
||||||
|
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.requireAtLeast(w, a, "viewer") || !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.requireAtLeast(w, a, "viewer") || !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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
|
"evobgp/internal/maintenance"
|
||||||
"evobgp/internal/pgmonitor"
|
"evobgp/internal/pgmonitor"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
|
|
||||||
@@ -21,6 +22,8 @@ type Server struct {
|
|||||||
store store.Backend
|
store store.Backend
|
||||||
pgPool *pgxpool.Pool
|
pgPool *pgxpool.Pool
|
||||||
pgMonitor *pgmonitor.Service
|
pgMonitor *pgmonitor.Service
|
||||||
|
maintConfig *maintenance.ConfigProvider
|
||||||
|
maintStats *maintenance.DBStatsProvider
|
||||||
jobs *jobs.Registry
|
jobs *jobs.Registry
|
||||||
bundlePriv ed25519.PrivateKey
|
bundlePriv ed25519.PrivateKey
|
||||||
keyResolver *apiKeyResolver
|
keyResolver *apiKeyResolver
|
||||||
@@ -66,13 +69,20 @@ func New(opts Options) (*Server, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var pgMon *pgmonitor.Service
|
var pgMon *pgmonitor.Service
|
||||||
|
var maintCfg *maintenance.ConfigProvider
|
||||||
|
var maintStats *maintenance.DBStatsProvider
|
||||||
if pool != nil {
|
if pool != nil {
|
||||||
pgMon = pgmonitor.NewService(pool)
|
pgMon = pgmonitor.NewService(pool)
|
||||||
|
maintCfg = maintenance.NewConfigProvider(backend)
|
||||||
|
_ = maintCfg.Reload(context.Background())
|
||||||
|
maintStats = maintenance.NewDBStatsProvider(pgMon)
|
||||||
}
|
}
|
||||||
s := &Server{
|
s := &Server{
|
||||||
store: backend,
|
store: backend,
|
||||||
pgPool: pool,
|
pgPool: pool,
|
||||||
pgMonitor: pgMon,
|
pgMonitor: pgMon,
|
||||||
|
maintConfig: maintCfg,
|
||||||
|
maintStats: maintStats,
|
||||||
jobs: reg,
|
jobs: reg,
|
||||||
bundlePriv: priv,
|
bundlePriv: priv,
|
||||||
keyResolver: resolver,
|
keyResolver: resolver,
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package jobs
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"evobgp/internal/maintenance"
|
||||||
|
"evobgp/internal/pgmonitor"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (w *Worker) maintenanceExecutor() *maintenance.PolicyExecutor {
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &maintenance.PolicyExecutor{Store: w.Store, Pool: w.PgPool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *Worker) runMaintenancePolicy(j *Job) {
|
||||||
|
if w == nil || w.PgPool == nil {
|
||||||
|
j.Fail("postgresql not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
policyID, _ := j.Meta["policy_id"].(string)
|
||||||
|
policyID = strings.TrimSpace(policyID)
|
||||||
|
if policyID == "" {
|
||||||
|
j.Fail("missing policy_id in job meta")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dryRun, _ := j.Meta["dry_run"].(bool)
|
||||||
|
actor, _ := j.Meta["actor_prefix"].(string)
|
||||||
|
ctx, cancel := j.workContext()
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
pol, err := w.Store.GetMaintenancePolicy(policyID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
|
j.Fail("maintenance policy not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.Fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
auditID, _ := pgmonitor.InsertMaintenanceAuditWithPolicy(ctx, w.PgPool, j.TenantID, actor, "maintenance_policy_run", pol.TableName, policyID, dryRun)
|
||||||
|
exec := w.maintenanceExecutor()
|
||||||
|
detail, err := exec.Execute(ctx, pol, dryRun)
|
||||||
|
var errMsg *string
|
||||||
|
status := StatusSucceeded
|
||||||
|
if err != nil {
|
||||||
|
s := err.Error()
|
||||||
|
errMsg = &s
|
||||||
|
status = StatusFailed
|
||||||
|
_ = w.Store.TouchMaintenancePolicyRun(policyID, status, s)
|
||||||
|
j.Fail(s)
|
||||||
|
} else {
|
||||||
|
j.mergeMeta(map[string]any{"maintenance": detail, "audit_id": auditID, "policy_id": policyID})
|
||||||
|
j.Succeed()
|
||||||
|
}
|
||||||
|
if auditID != "" {
|
||||||
|
_ = pgmonitor.FinishMaintenanceAudit(ctx, w.PgPool, auditID, status, detail, errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,7 @@ const (
|
|||||||
KindPostgresAnalyze = "postgres_analyze"
|
KindPostgresAnalyze = "postgres_analyze"
|
||||||
KindPostgresReindex = "postgres_reindex"
|
KindPostgresReindex = "postgres_reindex"
|
||||||
KindPostgresCleanup = "postgres_cleanup"
|
KindPostgresCleanup = "postgres_cleanup"
|
||||||
|
KindMaintenancePolicyRun = "maintenance_policy_run"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Worker executes queued jobs against store.Backend (memory or SQL).
|
// Worker executes queued jobs against store.Backend (memory or SQL).
|
||||||
@@ -188,6 +189,8 @@ func (w *Worker) Process(j *Job) {
|
|||||||
w.runPostgresMaint(j, "reindex")
|
w.runPostgresMaint(j, "reindex")
|
||||||
case KindPostgresCleanup:
|
case KindPostgresCleanup:
|
||||||
w.runPostgresCleanup(j)
|
w.runPostgresCleanup(j)
|
||||||
|
case KindMaintenancePolicyRun:
|
||||||
|
w.runMaintenancePolicy(j)
|
||||||
default:
|
default:
|
||||||
j.Fail("unknown job kind")
|
j.Fail("unknown job kind")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,12 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/jobs"
|
|
||||||
|
|
||||||
"github.com/robfig/cron/v3"
|
"github.com/robfig/cron/v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// StartScheduler enqueues maintenance_policy_run jobs when cron schedules match.
|
// StartScheduler enqueues maintenance_policy_run jobs when cron schedules match.
|
||||||
func StartScheduler(ctx context.Context, provider *ConfigProvider, reg *jobs.Registry, tick time.Duration) {
|
func StartScheduler(ctx context.Context, provider *ConfigProvider, enqueue func(policyID string, dryRun bool, idempotencyKey string), tick time.Duration) {
|
||||||
if provider == nil || reg == nil {
|
if provider == nil || enqueue == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if tick <= 0 {
|
if tick <= 0 {
|
||||||
@@ -76,14 +74,8 @@ func StartScheduler(ctx context.Context, provider *ConfigProvider, reg *jobs.Reg
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
lastFired[p.ID] = next
|
lastFired[p.ID] = next
|
||||||
dry := p.DryRunEnabled
|
|
||||||
idem := fmt.Sprintf("maint-%s-%d", p.ID, slot)
|
idem := fmt.Sprintf("maint-%s-%d", p.ID, slot)
|
||||||
key := idem
|
enqueue(p.ID, p.DryRunEnabled, idem)
|
||||||
_, _, _ = reg.Enqueue("", "maintenance_policy_run", &key, nil, map[string]any{
|
|
||||||
"policy_id": p.ID,
|
|
||||||
"dry_run": dry,
|
|
||||||
"trigger": "scheduler",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,12 +82,17 @@ func RunCleanup(ctx context.Context, pool *pgxpool.Pool, policy string, dryRun b
|
|||||||
|
|
||||||
// InsertMaintenanceAudit records an audit row at job start.
|
// InsertMaintenanceAudit records an audit row at job start.
|
||||||
func InsertMaintenanceAudit(ctx context.Context, pool *pgxpool.Pool, tenantID, actorPrefix, kind, table string, dryRun bool) (string, error) {
|
func InsertMaintenanceAudit(ctx context.Context, pool *pgxpool.Pool, tenantID, actorPrefix, kind, table string, dryRun bool) (string, error) {
|
||||||
|
return InsertMaintenanceAuditWithPolicy(ctx, pool, tenantID, actorPrefix, kind, table, "", dryRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertMaintenanceAuditWithPolicy records an audit row linked to maintenance_policy.
|
||||||
|
func InsertMaintenanceAuditWithPolicy(ctx context.Context, pool *pgxpool.Pool, tenantID, actorPrefix, kind, table, policyID string, dryRun bool) (string, error) {
|
||||||
id := uuid.New().String()
|
id := uuid.New().String()
|
||||||
_, err := pool.Exec(ctx, `
|
_, err := pool.Exec(ctx, `
|
||||||
INSERT INTO postgres_maintenance_audit
|
INSERT INTO postgres_maintenance_audit
|
||||||
(id, tenant_id, actor_prefix, kind, target_table, dry_run, status, created_at)
|
(id, tenant_id, actor_prefix, kind, target_table, policy_id, dry_run, status, created_at)
|
||||||
VALUES ($1, NULLIF($2,''), NULLIF($3,''), $4, NULLIF($5,''), $6, 'running', now())`,
|
VALUES ($1, NULLIF($2,''), NULLIF($3,''), $4, NULLIF($5,''), NULLIF($6,''), $7, 'running', now())`,
|
||||||
id, tenantID, actorPrefix, kind, table, dryRun)
|
id, tenantID, actorPrefix, kind, table, policyID, dryRun)
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user