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.
670 lines
18 KiB
Go
670 lines
18 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("store: not found")
|
|
ErrTenantScope = errors.New("store: tenant mismatch")
|
|
ErrInvalidInput = errors.New("store: invalid input")
|
|
)
|
|
|
|
// Memory is a development-oriented in-memory backend for control-plane API handlers.
|
|
type Memory struct {
|
|
mu sync.RWMutex
|
|
|
|
tenants map[string]*Tenant
|
|
modules map[string]*Module
|
|
revisions map[string]*Revision
|
|
speakers map[string]*Speaker
|
|
|
|
// speakerID -> latest published revision for evobgp-node pulls
|
|
publishedRevision map[string]publishedInfo
|
|
|
|
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
|
|
maintenancePolicies map[string]*MaintenancePolicy
|
|
maintConfigAudit []*MaintenancePolicyConfigAudit
|
|
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
|
|
|
// DemoIDs valid after SeedDemo()
|
|
demoTenantID string
|
|
demoModuleCDN string
|
|
demoModuleIP string
|
|
demoRevisionID string
|
|
demoSpeakerID string
|
|
}
|
|
|
|
type publishedInfo struct {
|
|
RevisionID string
|
|
PublishedAt time.Time
|
|
}
|
|
|
|
type apiKeyRec struct {
|
|
APIKey
|
|
TokenHash []byte
|
|
}
|
|
|
|
type Tenant struct {
|
|
ID string
|
|
Name string
|
|
Slug string
|
|
}
|
|
|
|
type Module struct {
|
|
ID string
|
|
TenantID string
|
|
Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES
|
|
Name string
|
|
Enabled bool
|
|
RefreshIntervalSec int // 0 = unset
|
|
CronExpr string // optional cron for scheduler (display / future use)
|
|
Priority int
|
|
DefaultCommunityID *string
|
|
DohProfileID *string // deprecated: first id in DohProfileIDs
|
|
DohProfileIDs []string
|
|
DohResolverPolicy string
|
|
LastRefreshedAt *time.Time
|
|
DeletedAt *time.Time
|
|
}
|
|
|
|
type Revision struct {
|
|
ID string
|
|
TenantID string
|
|
ModuleID string
|
|
ContentHash string
|
|
ParentRevisionID *string
|
|
CreatedAt time.Time
|
|
// MaterializedPrefixCount is the size of the prefix set for this revision (control-plane / render output).
|
|
MaterializedPrefixCount int
|
|
// PreviewFragments maps logical paths (e.g. bird.conf) to generated text.
|
|
PreviewFragments map[string]string
|
|
}
|
|
|
|
// BGPPeer maps to bgp_peer (+ display fields in meta).
|
|
type BGPPeer struct {
|
|
ID string `json:"id,omitempty"`
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
SpeakerID *string `json:"bgp_speaker_id"`
|
|
Name string `json:"name"`
|
|
Neighbor string `json:"neighbor"`
|
|
RemoteASN int64 `json:"remote_asn"`
|
|
Enabled bool `json:"enabled"`
|
|
SessionState string `json:"session_state"`
|
|
PoliciesJSON string `json:"policies_json"`
|
|
}
|
|
|
|
type Speaker struct {
|
|
ID string `json:"id,omitempty"`
|
|
TenantID string `json:"tenant_id,omitempty"`
|
|
Role string `json:"role"`
|
|
Endpoint string `json:"endpoint"`
|
|
LastAppliedRevisionID *string `json:"last_applied_revision_id"`
|
|
MetaJSON string `json:"meta_json"`
|
|
}
|
|
|
|
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),
|
|
maintenancePolicies: make(map[string]*MaintenancePolicy),
|
|
maintConfigAudit: nil,
|
|
runtimeLogCleanupAudit: nil,
|
|
}
|
|
}
|
|
|
|
// SeedDemo installs a minimal tenant, modules, revision, and speaker for local testing.
|
|
func (m *Memory) SeedDemo() {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
|
|
tid := uuid.NewString()
|
|
m.tenants[tid] = &Tenant{ID: tid, Name: "Demo", Slug: "demo"}
|
|
|
|
mCDN := uuid.NewString()
|
|
m.modules[mCDN] = &Module{
|
|
ID: mCDN,
|
|
TenantID: tid,
|
|
Type: "CDN_CIDRS",
|
|
Name: "demo-cdn",
|
|
Enabled: true,
|
|
RefreshIntervalSec: 3600,
|
|
CronExpr: "",
|
|
Priority: 10,
|
|
}
|
|
|
|
mIP := uuid.NewString()
|
|
m.modules[mIP] = &Module{
|
|
ID: mIP,
|
|
TenantID: tid,
|
|
Type: "IP_RANGES",
|
|
Name: "demo-static",
|
|
Enabled: true,
|
|
RefreshIntervalSec: 0,
|
|
CronExpr: "",
|
|
Priority: 20,
|
|
}
|
|
|
|
parent := uuid.NewString()
|
|
m.revisions[parent] = &Revision{
|
|
ID: parent,
|
|
TenantID: tid,
|
|
ModuleID: "",
|
|
ContentHash: "sha256:parent",
|
|
ParentRevisionID: nil,
|
|
CreatedAt: time.Now().UTC().Add(-time.Hour),
|
|
MaterializedPrefixCount: 0,
|
|
PreviewFragments: map[string]string{"bird.conf": "# parent revision\n"},
|
|
}
|
|
|
|
rid := uuid.NewString()
|
|
m.revisions[rid] = &Revision{
|
|
ID: rid,
|
|
TenantID: tid,
|
|
ModuleID: "",
|
|
ContentHash: "sha256:demo-rev-1",
|
|
ParentRevisionID: &parent,
|
|
CreatedAt: time.Now().UTC(),
|
|
MaterializedPrefixCount: 128,
|
|
PreviewFragments: map[string]string{
|
|
// Valid minimal BIRD 2 skeleton (see internal/birdfmt/testdata/scenarios/minimal/bird.conf).
|
|
"bird.conf": `# EvoBGP demo bundle
|
|
router id 192.0.2.1;
|
|
|
|
protocol device {
|
|
}
|
|
|
|
protocol direct {
|
|
ipv4;
|
|
ipv6;
|
|
}
|
|
`,
|
|
"bird.d/evobgp_demo.conf": "# static demo fragment\n",
|
|
},
|
|
}
|
|
|
|
sid := uuid.NewString()
|
|
m.speakers[sid] = &Speaker{
|
|
ID: sid,
|
|
TenantID: tid,
|
|
Role: "replica",
|
|
Endpoint: "10.0.0.2:179",
|
|
LastAppliedRevisionID: nil,
|
|
}
|
|
m.publishedRevision[sid] = publishedInfo{RevisionID: rid, PublishedAt: time.Now().UTC()}
|
|
|
|
p1 := uuid.NewString()
|
|
m.peers[p1] = &BGPPeer{
|
|
ID: p1,
|
|
TenantID: tid,
|
|
SpeakerID: &sid,
|
|
Name: "demo-upstream-4",
|
|
Neighbor: "198.51.100.2",
|
|
RemoteASN: 65001,
|
|
Enabled: true,
|
|
SessionState: "Established",
|
|
}
|
|
p2 := uuid.NewString()
|
|
m.peers[p2] = &BGPPeer{
|
|
ID: p2,
|
|
TenantID: tid,
|
|
SpeakerID: &sid,
|
|
Name: "demo-upstream-6",
|
|
Neighbor: "2001:db8::2",
|
|
RemoteASN: 65002,
|
|
Enabled: true,
|
|
SessionState: "Idle",
|
|
}
|
|
|
|
m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID = tid, mCDN, mIP, rid, sid
|
|
|
|
// Demo materialized prefixes for /revisions/{id}/prefixes
|
|
cid := uuid.NewString()
|
|
m.communities[cid] = &Community{ID: cid, TenantID: tid, Community: "demo-comm", Title: "Demo", ValueJSON: "{}"}
|
|
m.revPrefixes[rid] = []PrefixRow{
|
|
{Prefix: "203.0.113.0/24", CommunityID: &cid, Source: "demo"},
|
|
{Prefix: "2001:db8::/32", CommunityID: &cid, Source: "demo"},
|
|
}
|
|
}
|
|
|
|
// MaterializedPrefixStats returns max and sum of MaterializedPrefixCount across revisions.
|
|
func (m *Memory) MaterializedPrefixStats() (max int, sum int) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
for _, r := range m.revisions {
|
|
sum += r.MaterializedPrefixCount
|
|
if r.MaterializedPrefixCount > max {
|
|
max = r.MaterializedPrefixCount
|
|
}
|
|
}
|
|
return max, sum
|
|
}
|
|
|
|
// PeerCount returns the number of configured BGP peers.
|
|
func (m *Memory) PeerCount() int {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return len(m.peers)
|
|
}
|
|
|
|
// PeerSessionCountsByState returns peer counts grouped by SessionState.
|
|
func (m *Memory) PeerSessionCountsByState() map[string]int {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make(map[string]int)
|
|
for _, p := range m.peers {
|
|
st := strings.TrimSpace(p.SessionState)
|
|
if st == "" {
|
|
st = "unknown"
|
|
}
|
|
out[st]++
|
|
}
|
|
return out
|
|
}
|
|
|
|
// DemoIDs returns IDs from SeedDemo; empty strings if SeedDemo was not called.
|
|
func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
|
|
}
|
|
|
|
// Ping is a no-op for the in-memory backend.
|
|
func (m *Memory) Ping(ctx context.Context) error {
|
|
_ = ctx
|
|
return nil
|
|
}
|
|
|
|
// RunPeriodicMaintenance is a no-op for the in-memory backend.
|
|
func (m *Memory) RunPeriodicMaintenance(ctx context.Context) {
|
|
_ = ctx
|
|
}
|
|
|
|
// ListTenantIDs returns tenant ids sorted lexicographically.
|
|
func (m *Memory) ListTenantIDs() ([]string, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
out := make([]string, 0, len(m.tenants))
|
|
for id := range m.tenants {
|
|
out = append(out, id)
|
|
}
|
|
sort.Strings(out)
|
|
return out, nil
|
|
}
|
|
|
|
// CreateRenderRevision stores a new revision and its materialized prefixes.
|
|
func (m *Memory) CreateRenderRevision(revisionID, tenantID, moduleID string, parentRevisionID *string, contentHash string, previewFragments map[string]string, prefixes []PrefixRow) error {
|
|
if strings.TrimSpace(revisionID) == "" || strings.TrimSpace(moduleID) == "" || strings.TrimSpace(contentHash) == "" {
|
|
return ErrInvalidInput
|
|
}
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, exists := m.revisions[revisionID]; exists {
|
|
return ErrInvalidInput
|
|
}
|
|
mod, ok := m.modules[moduleID]
|
|
if !ok || mod.DeletedAt != nil || mod.TenantID != tenantID {
|
|
return ErrNotFound
|
|
}
|
|
if parentRevisionID != nil && *parentRevisionID != "" {
|
|
if pr, ok := m.revisions[*parentRevisionID]; !ok || pr.TenantID != tenantID {
|
|
return ErrNotFound
|
|
}
|
|
}
|
|
frag := make(map[string]string, len(previewFragments))
|
|
for k, v := range previewFragments {
|
|
frag[k] = v
|
|
}
|
|
parent := parentRevisionID
|
|
createdAt := time.Now().UTC()
|
|
for _, r := range m.revisions {
|
|
if r == nil || r.TenantID != tenantID {
|
|
continue
|
|
}
|
|
if !r.CreatedAt.Before(createdAt) {
|
|
createdAt = r.CreatedAt.Add(time.Microsecond)
|
|
}
|
|
}
|
|
m.revisions[revisionID] = &Revision{
|
|
ID: revisionID,
|
|
TenantID: tenantID,
|
|
ModuleID: moduleID,
|
|
ContentHash: contentHash,
|
|
ParentRevisionID: parent,
|
|
CreatedAt: createdAt,
|
|
MaterializedPrefixCount: len(prefixes),
|
|
PreviewFragments: frag,
|
|
}
|
|
if len(prefixes) > 0 {
|
|
cp := make([]PrefixRow, len(prefixes))
|
|
copy(cp, prefixes)
|
|
m.revPrefixes[revisionID] = cp
|
|
} else {
|
|
m.revPrefixes[revisionID] = nil
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ListModules returns modules for a tenant (sorted by priority, then name).
|
|
func (m *Memory) ListModules(tenantID string) []*Module {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var out []*Module
|
|
for _, mod := range m.modules {
|
|
if mod.TenantID == tenantID && mod.DeletedAt == nil {
|
|
out = append(out, mod)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Priority != out[j].Priority {
|
|
return out[i].Priority < out[j].Priority
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (m *Memory) ListModulesPage(tenantID, cursor string, limit int) ([]*Module, string, bool) {
|
|
all := m.ListModules(tenantID)
|
|
return PaginateOffset(all, cursor, limit)
|
|
}
|
|
|
|
// ListPeers returns BGP peers for a tenant (sorted by name).
|
|
func (m *Memory) ListPeers(tenantID string) []*BGPPeer {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var out []*BGPPeer
|
|
for _, p := range m.peers {
|
|
if p.TenantID == tenantID {
|
|
out = append(out, p)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out
|
|
}
|
|
|
|
func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
mod, ok := m.modules[moduleID]
|
|
if !ok || mod.DeletedAt != nil {
|
|
return nil, ErrNotFound
|
|
}
|
|
if mod.TenantID != tenantID {
|
|
return nil, ErrTenantScope
|
|
}
|
|
return mod, nil
|
|
}
|
|
|
|
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
return m.getRevisionLocked(tenantID, revisionID)
|
|
}
|
|
|
|
func (m *Memory) GetRevisionSummary(tenantID, revisionID string) (*Revision, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
rev, err := m.getRevisionLocked(tenantID, revisionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cp := *rev
|
|
cp.PreviewFragments = nil
|
|
return &cp, nil
|
|
}
|
|
|
|
func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
sp, ok := m.speakers[speakerID]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
if sp.TenantID != tenantID {
|
|
return nil, ErrTenantScope
|
|
}
|
|
return sp, nil
|
|
}
|
|
|
|
// ListSpeakersForTenant returns speakers for a tenant (IDs sorted) for deploy-all and similar operations.
|
|
func (m *Memory) ListSpeakersForTenant(tenantID string) []*Speaker {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var ids []string
|
|
for id, sp := range m.speakers {
|
|
if sp.TenantID == tenantID {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
sort.Strings(ids)
|
|
out := make([]*Speaker, 0, len(ids))
|
|
for _, id := range ids {
|
|
out = append(out, m.speakers[id])
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GetSpeakerAnyTenant resolves a speaker without tenant check (node API uses speaker id in path).
|
|
func (m *Memory) GetSpeakerAnyTenant(speakerID string) (*Speaker, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
sp, ok := m.speakers[speakerID]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
return sp, nil
|
|
}
|
|
|
|
func (m *Memory) LatestPublishedRevision(speakerID string) (revisionID string, publishedAt time.Time, err error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
info, ok := m.publishedRevision[speakerID]
|
|
if !ok {
|
|
return "", time.Time{}, ErrNotFound
|
|
}
|
|
return info.RevisionID, info.PublishedAt, nil
|
|
}
|
|
|
|
// CreateRollbackRevision adds a new revision that reuses content from sourceRevisionID (same hash & preview copy).
|
|
func (m *Memory) CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error) {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
src, ok := m.revisions[sourceRevisionID]
|
|
if !ok {
|
|
return "", ErrNotFound
|
|
}
|
|
if src.TenantID != tenantID {
|
|
return "", ErrTenantScope
|
|
}
|
|
newID = uuid.NewString()
|
|
frag := make(map[string]string, len(src.PreviewFragments))
|
|
for k, v := range src.PreviewFragments {
|
|
frag[k] = v
|
|
}
|
|
parent := sourceRevisionID
|
|
m.revisions[newID] = &Revision{
|
|
ID: newID,
|
|
TenantID: tenantID,
|
|
ModuleID: src.ModuleID,
|
|
ContentHash: src.ContentHash + ":rollback",
|
|
ParentRevisionID: &parent,
|
|
CreatedAt: time.Now().UTC(),
|
|
MaterializedPrefixCount: src.MaterializedPrefixCount,
|
|
PreviewFragments: frag,
|
|
}
|
|
if px, ok := m.revPrefixes[sourceRevisionID]; ok {
|
|
cp := make([]PrefixRow, len(px))
|
|
copy(cp, px)
|
|
m.revPrefixes[newID] = cp
|
|
}
|
|
return newID, nil
|
|
}
|
|
|
|
// SetLastAppliedRevision updates speaker state after deploy job (demo).
|
|
func (m *Memory) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
sp, ok := m.speakers[speakerID]
|
|
if !ok {
|
|
return ErrNotFound
|
|
}
|
|
if sp.TenantID != tenantID {
|
|
return ErrTenantScope
|
|
}
|
|
if _, ok := m.revisions[revisionID]; !ok {
|
|
return fmt.Errorf("%w: revision", ErrNotFound)
|
|
}
|
|
sp.LastAppliedRevisionID = &revisionID
|
|
return nil
|
|
}
|
|
|
|
// PublishRevisionForSpeaker marks latest bundle pointer for a replica speaker.
|
|
func (m *Memory) PublishRevisionForSpeaker(speakerID, revisionID string) error {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
if _, ok := m.speakers[speakerID]; !ok {
|
|
return ErrNotFound
|
|
}
|
|
if _, ok := m.revisions[revisionID]; !ok {
|
|
return fmt.Errorf("%w: revision", ErrNotFound)
|
|
}
|
|
m.publishedRevision[speakerID] = publishedInfo{RevisionID: revisionID, PublishedAt: time.Now().UTC()}
|
|
return nil
|
|
}
|
|
|
|
// RevisionDiff returns prefix set diff from materialized snapshots when present.
|
|
func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
_, err := m.getRevisionLocked(tenantID, aID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
_, err = m.getRevisionLocked(tenantID, bID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
setA := make(map[string]struct{})
|
|
setB := make(map[string]struct{})
|
|
for _, p := range m.revPrefixes[aID] {
|
|
setA[p.Prefix] = struct{}{}
|
|
}
|
|
for _, p := range m.revPrefixes[bID] {
|
|
setB[p.Prefix] = struct{}{}
|
|
}
|
|
var added, removed []string
|
|
unchanged := 0
|
|
for p := range setB {
|
|
if _, ok := setA[p]; !ok {
|
|
added = append(added, p)
|
|
} else {
|
|
unchanged++
|
|
}
|
|
}
|
|
for p := range setA {
|
|
if _, ok := setB[p]; !ok {
|
|
removed = append(removed, p)
|
|
}
|
|
}
|
|
sort.Strings(added)
|
|
sort.Strings(removed)
|
|
return map[string]any{
|
|
"revision_a": aID,
|
|
"revision_b": bID,
|
|
"prefixes": map[string]any{
|
|
"added": added,
|
|
"removed": removed,
|
|
"unchanged_count": unchanged,
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) {
|
|
rev, ok := m.revisions[revisionID]
|
|
if !ok {
|
|
return nil, ErrNotFound
|
|
}
|
|
if rev.TenantID != tenantID {
|
|
return nil, ErrTenantScope
|
|
}
|
|
return rev, nil
|
|
}
|
|
|
|
// ListRevisions returns recent revisions for tenant (newest first), cursor is opaque offset string.
|
|
func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool) {
|
|
if limit <= 0 {
|
|
limit = 50
|
|
}
|
|
m.mu.RLock()
|
|
defer m.mu.RUnlock()
|
|
var all []*Revision
|
|
for _, r := range m.revisions {
|
|
if r.TenantID != tenantID {
|
|
continue
|
|
}
|
|
if moduleID != "" && r.ModuleID != moduleID {
|
|
continue
|
|
}
|
|
all = append(all, r)
|
|
}
|
|
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 := 0
|
|
if cursor != "" {
|
|
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
|
off = n
|
|
}
|
|
}
|
|
end := off + limit
|
|
if end > len(all) {
|
|
end = len(all)
|
|
}
|
|
page := all[off:end]
|
|
if end < len(all) {
|
|
nextCursor = fmt.Sprintf("%d", end)
|
|
hasMore = true
|
|
}
|
|
return page, nextCursor, hasMore
|
|
}
|