feat: implement revision retention settings and pruning logic
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 44s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m5s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m12s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m24s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m19s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m21s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m6s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m21s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m21s
CI / changes (push) Successful in 6s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 44s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m5s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m1s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m1s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m12s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m24s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m19s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m21s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m6s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m21s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m21s
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.
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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, `
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<Array<{ id: number; key: string; value: string }>>([]);
|
||||
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<string, unknown>)) {
|
||||
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 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<h3 class="text-sm font-medium">Управление ревизиями</h3>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label for="revision-retention-minutes">
|
||||
Время жизни ревизий, мин (revision_retention_minutes)
|
||||
</Label>
|
||||
<Input
|
||||
id="revision-retention-minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="43200"
|
||||
bind:value={knownFields.revision_retention_minutes}
|
||||
placeholder="43200"
|
||||
/>
|
||||
{#if knownFieldErrors.revision_retention_minutes}
|
||||
<p class="text-sm text-red-600">{knownFieldErrors.revision_retention_minutes}</p>
|
||||
{/if}
|
||||
<p class="text-muted-foreground text-sm">
|
||||
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-medium">Дополнительные настройки (KV)</h3>
|
||||
|
||||
Reference in New Issue
Block a user