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)