Files
EvoBGP/internal/store/memory.go
T
Denozordec dc803bcb34
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
refactor(web): remove deprecated dashboard components and enhance KPI grid
- 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.
2026-08-31 10:15:59 +07:00

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
}