From 4f50350990a1a1aa31b357f60a40a61e3341ce27 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Wed, 8 Apr 2026 14:08:23 +0700 Subject: [PATCH] feat: implement revision retention settings and pruning logic Added functionality to handle revision retention minutes in global settings, including validation for input values. Introduced a new method to prune old revisions based on the specified retention period, ensuring that only the most recent revisions are kept. Updated the frontend to allow users to configure the revision retention setting, enhancing the management of revision lifecycles. --- internal/httpapi/routes_crud.go | 38 +++++++++++++++++ internal/pipeline/refresh.go | 45 ++++++++++++++++++++ internal/repository/postgres.go | 26 ++++++++++++ internal/store/backend.go | 1 + internal/store/memory.go | 56 +++++++++++++++++++++++++ web/src/routes/settings/+page.svelte | 61 ++++++++++++++++++++++++---- 6 files changed, 218 insertions(+), 9 deletions(-) diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 5abe5f9..f818c5b 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -1142,9 +1142,47 @@ func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } + if raw, ok := body["revision_retention_minutes"]; ok && raw != nil { + v, ok := parseRevisionRetentionMinutes(raw) + if !ok { + writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_retention_minutes must be an integer in range 15..43200") + return + } + body["revision_retention_minutes"] = v + } if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil { writeStoreErr(w, err) return } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } + +func parseRevisionRetentionMinutes(v any) (int, bool) { + const minMinutes = 15 + const maxMinutes = 30 * 24 * 60 + + var out int + switch x := v.(type) { + case float64: + if x != float64(int(x)) { + return 0, false + } + out = int(x) + case int: + out = x + case int64: + out = int(x) + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + if err != nil { + return 0, false + } + out = n + default: + return 0, false + } + if out < minMinutes || out > maxMinutes { + return 0, false + } + return out, true +} diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index c54721b..b70c380 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -30,6 +30,10 @@ const ( birdFilterNameV4 = "evobgp_export_v4" birdFilterNameV6 = "evobgp_export_v6" auxBirdFullExpanded = "_bird_full_expanded.conf" + revisionTTLKey = "revision_retention_minutes" + revisionMinTTLMin = 15 + revisionMaxTTLMin = 30 * 24 * 60 + revisionDefaultTTL = 30 * 24 * time.Hour ) // MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR). @@ -83,6 +87,7 @@ func RenderTenantRevision(ctx context.Context, st store.Backend, hc *http.Client if err := st.CreateRenderRevision(revisionID, tenantID, triggerModuleID, parent, hash, preview, agg); err != nil { return "", err } + applyRevisionRetention(st, tenantID) return revisionID, nil } @@ -844,6 +849,46 @@ func uint32FromSettingsMap(m map[string]any, key string) uint32 { return 0 } +func intFromSettingsMap(m map[string]any, key string) int { + v, ok := m[key] + if !ok || v == nil { + return 0 + } + switch x := v.(type) { + case float64: + return int(x) + case int: + return x + case int64: + return int(x) + case string: + n, err := strconv.Atoi(strings.TrimSpace(x)) + if err == nil { + return n + } + } + return 0 +} + +func applyRevisionRetention(st store.Backend, tenantID string) { + settings, err := st.ListGlobalSettings(tenantID) + if err != nil { + return + } + ttl := revisionDefaultTTL + if minutes := intFromSettingsMap(settings, revisionTTLKey); minutes > 0 { + if minutes < revisionMinTTLMin { + minutes = revisionMinTTLMin + } + if minutes > revisionMaxTTLMin { + minutes = revisionMaxTTLMin + } + ttl = time.Duration(minutes) * time.Minute + } + cutoff := time.Now().UTC().Add(-ttl) + _, _ = st.PruneRevisionsBefore(tenantID, cutoff) +} + type peerPolicyJSON struct { LocalIPv4 string `json:"local_ipv4"` LocalIPv6 string `json:"local_ipv6"` diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go index df7523f..65b1e3b 100644 --- a/internal/repository/postgres.go +++ b/internal/repository/postgres.go @@ -784,6 +784,32 @@ func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, erro }, nil } +func (p *Postgres) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) { + ctx := context.Background() + cmd, err := p.pool.Exec(ctx, ` + DELETE FROM config_revision AS cr + WHERE cr.tenant_id = $1 + AND cr.created_at < $2 + AND cr.id <> ( + SELECT id + FROM config_revision + WHERE tenant_id = $1 + ORDER BY created_at DESC + LIMIT 1 + ) + AND NOT EXISTS ( + SELECT 1 + FROM bgp_speaker AS sp + WHERE sp.tenant_id = $1 + AND (sp.last_applied_revision_id = cr.id OR sp.published_revision_id = cr.id) + )`, + tenantID, cutoff.UTC()) + if err != nil { + return 0, err + } + return int(cmd.RowsAffected()), nil +} + func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error { ctx := context.Background() tag, err := p.pool.Exec(ctx, ` diff --git a/internal/store/backend.go b/internal/store/backend.go index 38c197e..1d8a95a 100644 --- a/internal/store/backend.go +++ b/internal/store/backend.go @@ -72,6 +72,7 @@ type Backend interface { // CreateRenderRevision inserts a new config_revision (revID must be unique) with materialized prefixes and preview fragments. CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error RevisionDiff(tenantID, aID, bID string) (map[string]any, error) + PruneRevisionsBefore(tenantID string, cutoff time.Time) (deleted int, err error) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error PublishRevisionForSpeaker(speakerID, revisionID string) error LatestPublishedRevision(speakerID string) (revisionID string, publishedAt time.Time, err error) diff --git a/internal/store/memory.go b/internal/store/memory.go index d14b692..d70964d 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -565,6 +565,62 @@ func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) }, nil } +func (m *Memory) PruneRevisionsBefore(tenantID string, cutoff time.Time) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var newest *Revision + for _, rev := range m.revisions { + if rev.TenantID != tenantID { + continue + } + if newest == nil || rev.CreatedAt.After(newest.CreatedAt) { + newest = rev + } + } + if newest == nil { + return 0, nil + } + + protected := map[string]struct{}{ + newest.ID: {}, + } + for _, sp := range m.speakers { + if sp == nil || sp.TenantID != tenantID { + continue + } + if sp.LastAppliedRevisionID != nil && strings.TrimSpace(*sp.LastAppliedRevisionID) != "" { + protected[strings.TrimSpace(*sp.LastAppliedRevisionID)] = struct{}{} + } + } + for speakerID, info := range m.publishedRevision { + sp, ok := m.speakers[speakerID] + if !ok || sp == nil || sp.TenantID != tenantID { + continue + } + if strings.TrimSpace(info.RevisionID) != "" { + protected[strings.TrimSpace(info.RevisionID)] = struct{}{} + } + } + + deleted := 0 + for id, rev := range m.revisions { + if rev == nil || rev.TenantID != tenantID { + continue + } + if !rev.CreatedAt.Before(cutoff) { + continue + } + if _, keep := protected[id]; keep { + continue + } + delete(m.revisions, id) + delete(m.revPrefixes, id) + deleted++ + } + return deleted, nil +} + func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) { rev, ok := m.revisions[revisionID] if !ok { diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index d34df00..c825699 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -24,7 +24,8 @@ bird_local_ipv6: '', bird_local_asn: '', bird_bgp_source_ipv4: '', - bird_bgp_source_ipv6: '' + bird_bgp_source_ipv6: '', + revision_retention_minutes: '' }); let additionalSettings = $state>([]); let additionalIdCounter = $state(1); @@ -35,7 +36,8 @@ | 'bird_local_ipv6' | 'bird_local_asn' | 'bird_bgp_source_ipv4' - | 'bird_bgp_source_ipv6'; + | 'bird_bgp_source_ipv6' + | 'revision_retention_minutes'; const knownFieldKeys: KnownFieldKey[] = [ 'bird_router_id', @@ -43,7 +45,8 @@ 'bird_local_ipv6', 'bird_local_asn', 'bird_bgp_source_ipv4', - 'bird_bgp_source_ipv6' + 'bird_bgp_source_ipv6', + 'revision_retention_minutes' ]; function isValidIPv4(value: string): boolean { @@ -98,7 +101,8 @@ bird_local_ipv6: '', bird_local_asn: '', bird_bgp_source_ipv4: '', - bird_bgp_source_ipv6: '' + bird_bgp_source_ipv6: '', + revision_retention_minutes: '' }; const routerId = knownFields.bird_router_id.trim(); @@ -119,6 +123,20 @@ const bgpV6 = knownFields.bird_bgp_source_ipv6.trim(); if (bgpV6 && !isValidIPv6(bgpV6)) errors.bird_bgp_source_ipv6 = 'Введите корректный IPv6 адрес'; + const revisionRetentionMinutes = knownFields.revision_retention_minutes.trim(); + if (revisionRetentionMinutes) { + const ttl = Number(revisionRetentionMinutes); + if ( + !/^\d+$/.test(revisionRetentionMinutes) || + !Number.isInteger(ttl) || + ttl < 15 || + ttl > 43200 + ) { + errors.revision_retention_minutes = + 'TTL ревизий должен быть целым числом от 15 до 43200 минут'; + } + } + return errors; }); @@ -141,15 +159,16 @@ bird_local_ipv6: '', bird_local_asn: '', bird_bgp_source_ipv4: '', - bird_bgp_source_ipv6: '' + bird_bgp_source_ipv6: '', + revision_retention_minutes: '' }; const parsedAdditional: Array<{ id: number; key: string; value: string }> = []; for (const [key, value] of Object.entries(settings as Record)) { if (knownFieldKeys.includes(key as KnownFieldKey)) { - if (key === 'bird_local_asn') { - if (typeof value === 'number' && Number.isFinite(value)) parsedKnown.bird_local_asn = String(value); - else if (typeof value === 'string') parsedKnown.bird_local_asn = value; + if (key === 'bird_local_asn' || key === 'revision_retention_minutes') { + if (typeof value === 'number' && Number.isFinite(value)) parsedKnown[key] = String(value); + else if (typeof value === 'string') parsedKnown[key] = value; } else if (typeof value === 'string') { parsedKnown[key as KnownFieldKey] = value; } @@ -206,7 +225,7 @@ for (const key of knownFieldKeys) { const value = knownFields[key].trim(); if (!value || knownFieldErrors[key]) continue; - if (key === 'bird_local_asn') payload[key] = Number(value); + if (key === 'bird_local_asn' || key === 'revision_retention_minutes') payload[key] = Number(value); else payload[key] = value; } for (const entry of additionalSettings) { @@ -345,6 +364,30 @@ +
+

Управление ревизиями

+ +
+ + + {#if knownFieldErrors.revision_retention_minutes} +

{knownFieldErrors.revision_retention_minutes}

+ {/if} +

+ Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется. +

+
+
+

Дополнительные настройки (KV)