feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
This commit is contained in:
@@ -0,0 +1,494 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"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
|
||||
|
||||
// DemoIDs valid after SeedDemo()
|
||||
demoTenantID string
|
||||
demoModuleCDN string
|
||||
demoModuleIP string
|
||||
demoRevisionID string
|
||||
demoSpeakerID string
|
||||
}
|
||||
|
||||
type publishedInfo struct {
|
||||
RevisionID string
|
||||
PublishedAt time.Time
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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 is a minimal stand-in until SQL-backed bgp_peer is wired into the API.
|
||||
type BGPPeer struct {
|
||||
ID string
|
||||
TenantID string
|
||||
SpeakerID *string
|
||||
Name string
|
||||
Neighbor string
|
||||
SessionState string // e.g. Established, Idle, Connect (intent or cached ops view)
|
||||
}
|
||||
|
||||
type Speaker struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Role string
|
||||
Endpoint string
|
||||
LastAppliedRevisionID *string
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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",
|
||||
SessionState: "Established",
|
||||
}
|
||||
p2 := uuid.NewString()
|
||||
m.peers[p2] = &BGPPeer{
|
||||
ID: p2,
|
||||
TenantID: tid,
|
||||
SpeakerID: &sid,
|
||||
Name: "demo-upstream-6",
|
||||
Neighbor: "2001:db8::2",
|
||||
SessionState: "Idle",
|
||||
}
|
||||
|
||||
m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID = tid, mCDN, mIP, rid, sid
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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()
|
||||
rev, ok := m.revisions[revisionID]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if rev.TenantID != tenantID {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
return rev, 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,
|
||||
}
|
||||
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 a trivial JSON-friendly diff between prefix sets (empty for memory demo).
|
||||
func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
|
||||
_, err := m.getRevisionLocked(tenantID, aID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, err = m.getRevisionLocked(tenantID, bID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{
|
||||
"revision_a": aID,
|
||||
"revision_b": bID,
|
||||
"prefixes": map[string]any{
|
||||
"added": []string{},
|
||||
"removed": []string{},
|
||||
"unchanged_count": 0,
|
||||
},
|
||||
}, 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 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user