quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 46s
quality / web (push) Successful in 1m16s
quality / go (push) Successful in 2m42s
quality / bird2 (push) Successful in 16s
CD / quality (push) Successful in 5m19s
CD / publish (push) Successful in 7m19s
- Deleted unused components: `DashboardActivityTimeline`, `DashboardFramePanel`, `DashboardModulesGrid`, `DashboardRecentJobsGrid`, and `DashboardRecentRevisionsGrid` to streamline the dashboard. - Updated `DashboardKpiGrid` to improve KPI display logic, including progress indicators and enhanced badge functionality. - Refactored `DashboardNetworkHealth` to provide better status representation based on loading states and network conditions. - Introduced new properties for KPI cards to support progress tracking and improved visual feedback. This cleanup aims to enhance performance and maintainability of the dashboard while providing a better user experience.
729 lines
20 KiB
Go
729 lines
20 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
|
|
|
|
peerDiscoveries map[string]*BGPPeerDiscovery
|
|
|
|
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
|
|
snapshotLocks sync.Map // key -> *sync.Mutex (per-module snapshot RMW)
|
|
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
|
domainResolveCache map[string]*DomainResolveCacheEntry
|
|
apiKeys map[string]*apiKeyRec
|
|
firewallClients map[string]*firewallClientRec
|
|
firewallRules map[string]*FirewallRule
|
|
firewallHashIndex map[string]string // hex hash -> client id
|
|
maintenancePolicies map[string]*MaintenancePolicy
|
|
maintConfigAudit []*MaintenancePolicyConfigAudit
|
|
runtimeLogCleanupAudit []*RuntimeLogCleanupAudit
|
|
auditLog []*AuditEntry
|
|
|
|
// 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 firewallClientRec struct {
|
|
FirewallClient
|
|
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
|
|
CreatedByUserID string // portal JWT sub; empty = system / API key
|
|
InputHash string // ingest fingerprint; empty = not yet computed
|
|
}
|
|
|
|
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"`
|
|
CreatedByUserID string `json:"created_by_user_id,omitempty"`
|
|
}
|
|
|
|
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),
|
|
peerDiscoveries: make(map[string]*BGPPeerDiscovery),
|
|
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),
|
|
domainResolveCache: make(map[string]*DomainResolveCacheEntry),
|
|
apiKeys: make(map[string]*apiKeyRec),
|
|
firewallClients: make(map[string]*firewallClientRec),
|
|
firewallRules: make(map[string]*FirewallRule),
|
|
firewallHashIndex: make(map[string]string),
|
|
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
|
|
})
|
|
for i := range out {
|
|
out[i] = cloneModule(out[i])
|
|
}
|
|
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 cloneModule(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
|
|
}
|
|
|
|
func cloneStringPtr(s *string) *string {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
v := *s
|
|
return &v
|
|
}
|
|
|
|
func (m *Memory) clearModuleInputHashLocked(moduleID string) {
|
|
if mod, ok := m.modules[moduleID]; ok && mod != nil {
|
|
mod.InputHash = ""
|
|
}
|
|
}
|
|
|
|
func (m *Memory) clearTenantModuleHashesLocked(tenantID string) {
|
|
for _, mod := range m.modules {
|
|
if mod != nil && mod.TenantID == tenantID && mod.DeletedAt == nil {
|
|
mod.InputHash = ""
|
|
}
|
|
}
|
|
}
|
|
|
|
func cloneModule(m *Module) *Module {
|
|
if m == nil {
|
|
return nil
|
|
}
|
|
cp := *m
|
|
cp.DefaultCommunityID = cloneStringPtr(m.DefaultCommunityID)
|
|
cp.DohProfileID = cloneStringPtr(m.DohProfileID)
|
|
cp.DohProfileIDs = append([]string(nil), m.DohProfileIDs...)
|
|
cp.LastRefreshedAt = cloneTime(m.LastRefreshedAt)
|
|
cp.DeletedAt = cloneTime(m.DeletedAt)
|
|
return &cp
|
|
}
|