feat(store): add MaintenancePolicy CRUD backend

Типы maintenance_policy, методы store.Backend и реализации для PostgreSQL и in-memory.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-12 13:20:27 +07:00
co-authored by Cursor
parent 948dac34fd
commit 07c3de4939
5 changed files with 679 additions and 28 deletions
+10
View File
@@ -115,6 +115,16 @@ type Backend interface {
// RunPeriodicMaintenance prunes stale DB rows (no-op for in-memory).
RunPeriodicMaintenance(ctx context.Context)
// Maintenance policies (instance-scoped PostgreSQL maintenance configuration).
ListMaintenancePolicies(cursor string, limit int) ([]*MaintenancePolicy, string, bool, error)
GetMaintenancePolicy(id string) (*MaintenancePolicy, error)
CreateMaintenancePolicy(in *MaintenancePolicy) (*MaintenancePolicy, error)
UpdateMaintenancePolicy(id string, patch *MaintenancePolicyPatch) (*MaintenancePolicy, error)
DeleteMaintenancePolicy(id string) error
TouchMaintenancePolicyRun(id, status, errMsg string) error
AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error
ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error)
}
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
+89
View File
@@ -0,0 +1,89 @@
package store
import (
"strings"
"time"
)
// Vacuum strategy values for maintenance_policy.vacuum_strategy.
const (
VacuumStrategyNone = "none"
VacuumStrategyVacuum = "vacuum"
VacuumStrategyAnalyze = "analyze"
VacuumStrategyVacuumAnalyze = "vacuum_analyze"
VacuumStrategyReindex = "reindex"
)
// MaintenancePolicy is an instance-scoped PostgreSQL maintenance policy (control plane DB).
type MaintenancePolicy struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
TableName string `json:"table_name"`
Condition string `json:"condition"`
RetentionPeriodSec *int `json:"retention_period_sec,omitempty"`
MaxRows *int `json:"max_rows,omitempty"`
VacuumStrategy string `json:"vacuum_strategy"`
Schedule string `json:"schedule"`
Enabled bool `json:"enabled"`
DryRunEnabled bool `json:"dry_run_enabled"`
LastRunAt *time.Time `json:"last_run_at,omitempty"`
LastStatus string `json:"last_status,omitempty"`
LastError string `json:"last_error,omitempty"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
// MaintenancePolicyPatch is a partial update for maintenance_policy.
type MaintenancePolicyPatch struct {
Name *string `json:"name,omitempty"`
TableName *string `json:"table_name,omitempty"`
Condition *string `json:"condition,omitempty"`
RetentionPeriodSec *int `json:"retention_period_sec,omitempty"`
MaxRows *int `json:"max_rows,omitempty"`
VacuumStrategy *string `json:"vacuum_strategy,omitempty"`
Schedule *string `json:"schedule,omitempty"`
Enabled *bool `json:"enabled,omitempty"`
DryRunEnabled *bool `json:"dry_run_enabled,omitempty"`
}
// MaintenancePolicyConfigAudit is a configuration change log entry.
type MaintenancePolicyConfigAudit struct {
ID string `json:"id"`
PolicyID string `json:"policy_id,omitempty"`
ActorPrefix string `json:"actor_prefix"`
Action string `json:"action"`
Before map[string]any `json:"before,omitempty"`
After map[string]any `json:"after,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ValidVacuumStrategy reports whether s is an allowed vacuum_strategy value.
func ValidVacuumStrategy(s string) bool {
switch strings.TrimSpace(s) {
case VacuumStrategyNone, VacuumStrategyVacuum, VacuumStrategyAnalyze,
VacuumStrategyVacuumAnalyze, VacuumStrategyReindex:
return true
default:
return false
}
}
// NormalizeMaintenancePolicyCondition returns a safe default WHERE fragment.
func NormalizeMaintenancePolicyCondition(condition string) string {
c := strings.TrimSpace(condition)
if c == "" {
return "true"
}
return c
}
// ValidateMaintenancePolicyInput checks required fields for create/update payloads.
func ValidateMaintenancePolicyInput(name, tableName, vacuumStrategy, schedule string) error {
if strings.TrimSpace(name) == "" || strings.TrimSpace(tableName) == "" || strings.TrimSpace(schedule) == "" {
return ErrInvalidInput
}
if !ValidVacuumStrategy(vacuumStrategy) {
return ErrInvalidInput
}
return nil
}
+32 -28
View File
@@ -33,17 +33,19 @@ type Memory struct {
peers map[string]*BGPPeer
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
dohProfiles map[string]*DohProfile
communities map[string]*Community
cdnSources map[string]*CDNSource
asEntries map[string]*ASEntry
domainEnt map[string]*DomainEntry
ipRanges map[string]*IPRangeEntry
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
revPrefixes map[string][]PrefixRow
moduleSnapshots map[string]*moduleSnapshotRec
asnPrefixCache map[int64]*ASNPrefixCacheEntry
apiKeys map[string]*apiKeyRec
maintenancePolicies map[string]*MaintenancePolicy
maintConfigAudit []*MaintenancePolicyConfigAudit
// DemoIDs valid after SeedDemo()
demoTenantID string
@@ -123,23 +125,25 @@ type Speaker struct {
func NewMemory() *Memory {
return &Memory{
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
dohProfiles: make(map[string]*DohProfile),
communities: make(map[string]*Community),
cdnSources: make(map[string]*CDNSource),
asEntries: make(map[string]*ASEntry),
domainEnt: make(map[string]*DomainEntry),
ipRanges: make(map[string]*IPRangeEntry),
settings: make(map[string]map[string]any),
revPrefixes: make(map[string][]PrefixRow),
moduleSnapshots: make(map[string]*moduleSnapshotRec),
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
apiKeys: make(map[string]*apiKeyRec),
maintenancePolicies: make(map[string]*MaintenancePolicy),
maintConfigAudit: nil,
}
}
+244
View File
@@ -0,0 +1,244 @@
package store
import (
"sort"
"strconv"
"strings"
"time"
"github.com/google/uuid"
)
func (m *Memory) ListMaintenancePolicies(cursor string, limit int) ([]*MaintenancePolicy, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
all := make([]*MaintenancePolicy, 0, len(m.maintenancePolicies))
for _, p := range m.maintenancePolicies {
all = append(all, p)
}
sort.Slice(all, func(i, j int) bool {
if all[i].CreatedAt.Equal(all[j].CreatedAt) {
return all[i].ID > all[j].ID
}
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(all) {
end = len(all)
} else if end < len(all) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(all) {
return nil, "", false, nil
}
out := make([]*MaintenancePolicy, end-off)
copy(out, all[off:end])
return out, next, hasMore, nil
}
func (m *Memory) GetMaintenancePolicy(id string) (*MaintenancePolicy, error) {
m.mu.RLock()
defer m.mu.RUnlock()
p, ok := m.maintenancePolicies[id]
if !ok {
return nil, ErrNotFound
}
return cloneMaintenancePolicy(p), nil
}
func (m *Memory) CreateMaintenancePolicy(in *MaintenancePolicy) (*MaintenancePolicy, error) {
if in == nil {
return nil, ErrInvalidInput
}
vacuum := in.VacuumStrategy
if vacuum == "" {
vacuum = VacuumStrategyNone
}
if err := ValidateMaintenancePolicyInput(in.Name, in.TableName, vacuum, in.Schedule); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now().UTC()
id := uuid.NewString()
p := &MaintenancePolicy{
ID: id,
Name: strings.TrimSpace(in.Name),
TableName: strings.TrimSpace(in.TableName),
Condition: NormalizeMaintenancePolicyCondition(in.Condition),
RetentionPeriodSec: in.RetentionPeriodSec,
MaxRows: in.MaxRows,
VacuumStrategy: vacuum,
Schedule: strings.TrimSpace(in.Schedule),
Enabled: in.Enabled,
DryRunEnabled: in.DryRunEnabled,
CreatedAt: now,
UpdatedAt: now,
}
m.maintenancePolicies[id] = p
return cloneMaintenancePolicy(p), nil
}
func (m *Memory) UpdateMaintenancePolicy(id string, patch *MaintenancePolicyPatch) (*MaintenancePolicy, error) {
if patch == nil {
return nil, ErrInvalidInput
}
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.maintenancePolicies[id]
if !ok {
return nil, ErrNotFound
}
if patch.Name != nil {
p.Name = strings.TrimSpace(*patch.Name)
}
if patch.TableName != nil {
p.TableName = strings.TrimSpace(*patch.TableName)
}
if patch.Condition != nil {
p.Condition = NormalizeMaintenancePolicyCondition(*patch.Condition)
}
if patch.RetentionPeriodSec != nil {
p.RetentionPeriodSec = patch.RetentionPeriodSec
}
if patch.MaxRows != nil {
p.MaxRows = patch.MaxRows
}
if patch.VacuumStrategy != nil {
if !ValidVacuumStrategy(*patch.VacuumStrategy) {
return nil, ErrInvalidInput
}
p.VacuumStrategy = strings.TrimSpace(*patch.VacuumStrategy)
}
if patch.Schedule != nil {
p.Schedule = strings.TrimSpace(*patch.Schedule)
}
if patch.Enabled != nil {
p.Enabled = *patch.Enabled
}
if patch.DryRunEnabled != nil {
p.DryRunEnabled = *patch.DryRunEnabled
}
if err := ValidateMaintenancePolicyInput(p.Name, p.TableName, p.VacuumStrategy, p.Schedule); err != nil {
return nil, err
}
p.UpdatedAt = time.Now().UTC()
return cloneMaintenancePolicy(p), nil
}
func (m *Memory) DeleteMaintenancePolicy(id string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.maintenancePolicies[id]; !ok {
return ErrNotFound
}
delete(m.maintenancePolicies, id)
return nil
}
func (m *Memory) TouchMaintenancePolicyRun(id, status, errMsg string) error {
m.mu.Lock()
defer m.mu.Unlock()
p, ok := m.maintenancePolicies[id]
if !ok {
return ErrNotFound
}
now := time.Now().UTC()
p.LastRunAt = &now
p.LastStatus = status
p.LastError = errMsg
p.UpdatedAt = now
return nil
}
func (m *Memory) AppendMaintenancePolicyConfigAudit(actor, policyID, action string, before, after map[string]any) error {
m.mu.Lock()
defer m.mu.Unlock()
row := &MaintenancePolicyConfigAudit{
ID: uuid.NewString(),
PolicyID: policyID,
ActorPrefix: strings.TrimSpace(actor),
Action: action,
Before: before,
After: after,
CreatedAt: time.Now().UTC(),
}
m.maintConfigAudit = append(m.maintConfigAudit, row)
return nil
}
func (m *Memory) ListMaintenancePolicyConfigAudit(cursor string, limit int) ([]*MaintenancePolicyConfigAudit, string, bool, error) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
all := append([]*MaintenancePolicyConfigAudit(nil), m.maintConfigAudit...)
sort.Slice(all, func(i, j int) bool {
if all[i].CreatedAt.Equal(all[j].CreatedAt) {
return all[i].ID > all[j].ID
}
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := parseMaintCursor(cursor)
end := off + limit
next := ""
hasMore := false
if end > len(all) {
end = len(all)
} else if end < len(all) {
hasMore = true
next = formatMaintCursor(end)
}
if off >= len(all) {
return nil, "", false, nil
}
out := make([]*MaintenancePolicyConfigAudit, end-off)
copy(out, all[off:end])
return out, next, hasMore, nil
}
func cloneMaintenancePolicy(p *MaintenancePolicy) *MaintenancePolicy {
if p == nil {
return nil
}
cp := *p
if p.RetentionPeriodSec != nil {
v := *p.RetentionPeriodSec
cp.RetentionPeriodSec = &v
}
if p.MaxRows != nil {
v := *p.MaxRows
cp.MaxRows = &v
}
if p.LastRunAt != nil {
t := *p.LastRunAt
cp.LastRunAt = &t
}
return &cp
}
func parseMaintCursor(cursor string) int {
if cursor == "" {
return 0
}
var off int
for _, r := range cursor {
if r < '0' || r > '9' {
return 0
}
off = off*10 + int(r-'0')
}
return off
}
func formatMaintCursor(off int) string {
return strconv.Itoa(off)
}