feat(revisions): add pruning estimate and cleanup endpoints
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 32s
CI / go (push) Successful in 57s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 3m18s

Implemented new endpoints for estimating and pruning revisions, including detailed schemas for requests and responses. The `RevisionPruneEstimate` and `RevisionPruneResult` components were added to the OpenAPI documentation, enhancing the API's functionality for managing revision retention. Updated the backend to support these operations and integrated them into the tenant settings UI for improved user interaction.
This commit is contained in:
Denozordec
2026-06-12 21:56:36 +07:00
parent 5dbdac3d2c
commit f39df7c4bf
18 changed files with 1146 additions and 130 deletions
+2
View File
@@ -59,6 +59,8 @@ func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions)
m.HandleFunc("GET /revisions/prune-estimate", s.handleRevisionPruneEstimate)
m.HandleFunc("POST /revisions/prune", s.handleRevisionPrune)
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
m.HandleFunc("GET /revisions/{revision_id}/diagnostic-log", s.handleRevisionDiagnosticLog)
+101
View File
@@ -0,0 +1,101 @@
package httpapi
import (
"encoding/json"
"net/http"
"strconv"
"time"
"evobgp/internal/pipeline"
)
func (s *Server) resolveRevisionRetentionMinutesQuery(r *http.Request, tenantID string) (minutes int, ok bool) {
if raw := r.URL.Query().Get("retention_minutes"); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil {
return 0, false
}
if n < pipeline.RevisionMinTTLMin || n > pipeline.RevisionMaxTTLMin {
return 0, false
}
return n, true
}
return s.defaultRevisionRetentionMinutes(tenantID)
}
func (s *Server) defaultRevisionRetentionMinutes(tenantID string) (int, bool) {
settings, err := s.store.ListGlobalSettings(tenantID)
if err != nil {
return pipeline.ClampRevisionRetentionMinutes(0), true
}
return pipeline.ClampRevisionRetentionMinutes(pipeline.RevisionRetentionMinutesFromSettings(settings)), true
}
func (s *Server) resolveRevisionRetentionMinutesBody(r *http.Request, tenantID string) (minutes int, ok bool) {
var body struct {
RetentionMinutes *int `json:"retention_minutes"`
}
if r.Body != nil && r.ContentLength != 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
return 0, false
}
if body.RetentionMinutes != nil {
m := *body.RetentionMinutes
if m < pipeline.RevisionMinTTLMin || m > pipeline.RevisionMaxTTLMin {
return 0, false
}
return m, true
}
}
return s.defaultRevisionRetentionMinutes(tenantID)
}
func (s *Server) handleRevisionPruneEstimate(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "viewer") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesQuery(r, a.TenantID)
if !valid {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "retention_minutes must be an integer in range 15..43200")
return
}
cutoff := pipeline.RevisionCutoffFromMinutes(minutes)
est, err := s.store.EstimateRevisionPrune(a.TenantID, cutoff, minutes)
if err != nil {
writeInternalError(w, "revision prune estimate", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"retention_minutes": est.RetentionMinutes,
"cutoff_at": est.CutoffAt.UTC().Format(time.RFC3339Nano),
"revision_count": est.RevisionCount,
"prefix_row_count": est.PrefixRowCount,
"orphan_snapshot_count": est.OrphanSnapshotCount,
"bytes_estimate": est.BytesEstimate,
})
}
func (s *Server) handleRevisionPrune(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok || !s.requireAtLeast(w, a, "operator") {
return
}
minutes, valid := s.resolveRevisionRetentionMinutesBody(r, a.TenantID)
if !valid {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "retention_minutes must be an integer in range 15..43200")
return
}
cutoff := pipeline.RevisionCutoffFromMinutes(minutes)
res, err := s.store.PruneRevisionsWithStats(a.TenantID, cutoff)
if err != nil {
writeInternalError(w, "revision prune", err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"deleted_revisions": res.DeletedRevisions,
"deleted_prefix_snapshots": res.DeletedPrefixSnapshots,
"deleted_prefix_rows": res.DeletedPrefixRows,
"bytes_estimate": res.BytesEstimate,
})
}
@@ -0,0 +1,94 @@
package httpapi
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestRevisionPruneEstimateViewerOK(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/revisions/prune-estimate?retention_minutes=15", nil)
req.Header.Set("Authorization", "Bearer vwkey")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body map[string]any
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if _, ok := body["revision_count"]; !ok {
t.Fatalf("missing revision_count: %v", body)
}
}
func TestRevisionPruneViewerForbidden(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "vwkey|"+tenant+"|viewer")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/revisions/prune", strings.NewReader(`{"retention_minutes":43200}`))
req.Header.Set("Authorization", "Bearer vwkey")
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusForbidden {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
}
func TestRevisionPruneOperatorOK(t *testing.T) {
srv, err := New(Options{InsecureDev: true, SeedDemo: true})
if err != nil {
t.Fatal(err)
}
defer srv.Close()
tenant, _, _, _, _ := srv.Store().DemoIDs()
mustSetTestAPIKeys(t, srv, "opkey|"+tenant+"|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
req, _ := http.NewRequest(http.MethodPost, ts.URL+"/v1/revisions/prune", strings.NewReader(`{"retention_minutes":15}`))
req.Header.Set("Authorization", "Bearer opkey")
req.Header.Set("Content-Type", "application/json")
resp, err := ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
}