feat: enhance EvoBGP with new command-line options for the evobgp-agent, including a watch command for periodic configuration updates. Update go.mod with additional dependencies and improve Docker Compose setup for new services, including NATS and various worker components.
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
package store
|
||||
|
||||
import "time"
|
||||
|
||||
// Backend is the persistence abstraction for the control plane (memory, PostgreSQL, SQLite).
|
||||
type Backend interface {
|
||||
MaterializedPrefixStats() (max int, sum int)
|
||||
PeerCount() int
|
||||
PeerSessionCountsByState() map[string]int
|
||||
// DemoIDs is non-empty only after SeedDemo (memory or seeded SQL).
|
||||
DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string)
|
||||
|
||||
ListModules(tenantID string) []*Module
|
||||
GetModule(tenantID, moduleID string) (*Module, error)
|
||||
CreateModule(tenantID string, in *Module) (*Module, error)
|
||||
UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error)
|
||||
SoftDeleteModule(tenantID, moduleID string) error
|
||||
|
||||
ListCDNSources(tenantID, moduleID string) ([]*CDNSource, error)
|
||||
CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDNSource, error)
|
||||
UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDNSourcePatch) (*CDNSource, error)
|
||||
DeleteCDNSource(tenantID, moduleID, sourceID string) error
|
||||
|
||||
ListASEntries(tenantID, moduleID string) ([]*ASEntry, error)
|
||||
CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error)
|
||||
UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntryPatch) (*ASEntry, error)
|
||||
DeleteASEntry(tenantID, moduleID, entryID string) error
|
||||
|
||||
ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error)
|
||||
CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error)
|
||||
UpdateDomainEntry(tenantID, moduleID, entryID string, patch *DomainEntryPatch) (*DomainEntry, error)
|
||||
DeleteDomainEntry(tenantID, moduleID, entryID string) error
|
||||
|
||||
ListIPRangeEntries(tenantID, moduleID string) ([]*IPRangeEntry, error)
|
||||
CreateIPRangeEntry(tenantID, moduleID string, in *IPRangeEntry) (*IPRangeEntry, error)
|
||||
UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *IPRangePatch) (*IPRangeEntry, error)
|
||||
DeleteIPRangeEntry(tenantID, moduleID, entryID string) error
|
||||
|
||||
ListDohProfiles(tenantID string) ([]*DohProfile, error)
|
||||
GetDohProfile(tenantID, id string) (*DohProfile, error)
|
||||
CreateDohProfile(tenantID string, in *DohProfile) (*DohProfile, error)
|
||||
UpdateDohProfile(tenantID, id string, patch *DohProfilePatch) (*DohProfile, error)
|
||||
DeleteDohProfile(tenantID, id string) error
|
||||
|
||||
ListCommunities(tenantID string) ([]*Community, error)
|
||||
GetCommunity(tenantID, id string) (*Community, error)
|
||||
CreateCommunity(tenantID string, in *Community) (*Community, error)
|
||||
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
||||
DeleteCommunity(tenantID, id string) error
|
||||
|
||||
ListPeers(tenantID string) []*BGPPeer
|
||||
GetPeer(tenantID, id string) (*BGPPeer, error)
|
||||
CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error)
|
||||
UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error)
|
||||
DeletePeer(tenantID, id string) error
|
||||
|
||||
ListSpeakersForTenant(tenantID string) []*Speaker
|
||||
GetSpeaker(tenantID, speakerID string) (*Speaker, error)
|
||||
GetSpeakerAnyTenant(speakerID string) (*Speaker, error)
|
||||
CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error)
|
||||
UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error)
|
||||
|
||||
GetRevision(tenantID, revisionID string) (*Revision, error)
|
||||
ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool)
|
||||
ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) (prefixes []PrefixRow, next string, more bool)
|
||||
CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error)
|
||||
RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
|
||||
SetLastAppliedRevision(tenantID, speakerID, revisionID string) error
|
||||
PublishRevisionForSpeaker(speakerID, revisionID string) error
|
||||
LatestPublishedRevision(speakerID string) (revisionID string, publishedAt time.Time, err error)
|
||||
|
||||
ListGlobalSettings(tenantID string) (map[string]any, error)
|
||||
PatchGlobalSettings(tenantID string, patch map[string]any) error
|
||||
}
|
||||
|
||||
// ModulePatch is a partial update for module.
|
||||
type ModulePatch struct {
|
||||
Name *string
|
||||
Enabled *bool
|
||||
Priority *int
|
||||
RefreshIntervalSec *int
|
||||
CronExpr *string
|
||||
DefaultCommunityID *string
|
||||
DohProfileID *string
|
||||
}
|
||||
|
||||
// CDNSource is a row under a CDN module.
|
||||
type CDNSource struct {
|
||||
ID string
|
||||
ModuleID string
|
||||
SourceKind string
|
||||
URL string
|
||||
Etag string
|
||||
RefreshIntervalSec *int
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type CDNSourcePatch struct {
|
||||
SourceKind *string
|
||||
URL *string
|
||||
Etag *string
|
||||
RefreshIntervalSec *int
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type ASEntry struct {
|
||||
ID string
|
||||
ModuleID string
|
||||
ASN *int64
|
||||
Prefix *string
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type ASEntryPatch struct {
|
||||
ASN *int64
|
||||
Prefix *string
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type DomainEntry struct {
|
||||
ID string
|
||||
ModuleID string
|
||||
FQDN string
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type DomainEntryPatch struct {
|
||||
FQDN *string
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type IPRangeEntry struct {
|
||||
ID string
|
||||
ModuleID string
|
||||
Prefix string
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type IPRangePatch struct {
|
||||
Prefix *string
|
||||
CommunityID *string
|
||||
}
|
||||
|
||||
type DohProfile struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Name string
|
||||
URL string
|
||||
TimeoutMs *int
|
||||
SecretRef *string
|
||||
}
|
||||
|
||||
type DohProfilePatch struct {
|
||||
Name *string
|
||||
URL *string
|
||||
TimeoutMs *int
|
||||
SecretRef *string
|
||||
}
|
||||
|
||||
type Community struct {
|
||||
ID string
|
||||
TenantID string
|
||||
Name string
|
||||
Kind string
|
||||
ValueJSON string
|
||||
}
|
||||
|
||||
type CommunityPatch struct {
|
||||
Name *string
|
||||
Kind *string
|
||||
ValueJSON *string
|
||||
}
|
||||
|
||||
type PeerPatch struct {
|
||||
Neighbor *string
|
||||
RemoteASN *int64
|
||||
SpeakerID *string
|
||||
Enabled *bool
|
||||
Name *string
|
||||
SessionState *string
|
||||
PoliciesJSON *string
|
||||
}
|
||||
|
||||
type SpeakerPatch struct {
|
||||
Role *string
|
||||
Endpoint *string
|
||||
MetaJSON *string
|
||||
}
|
||||
|
||||
// PrefixRow is one materialized prefix for GET /revisions/.../prefixes.
|
||||
type PrefixRow struct {
|
||||
Prefix string
|
||||
CommunityID *string
|
||||
Source string
|
||||
}
|
||||
+81
-16
@@ -32,6 +32,15 @@ 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
|
||||
|
||||
// DemoIDs valid after SeedDemo()
|
||||
demoTenantID string
|
||||
demoModuleCDN string
|
||||
@@ -59,9 +68,10 @@ type Module struct {
|
||||
Enabled bool
|
||||
RefreshIntervalSec int // 0 = unset
|
||||
CronExpr string // optional cron for scheduler (display / future use)
|
||||
Priority int
|
||||
DefaultCommunityID *string
|
||||
DohProfileID *string
|
||||
Priority int
|
||||
DefaultCommunityID *string
|
||||
DohProfileID *string
|
||||
DeletedAt *time.Time
|
||||
}
|
||||
|
||||
type Revision struct {
|
||||
@@ -77,14 +87,17 @@ type Revision struct {
|
||||
PreviewFragments map[string]string
|
||||
}
|
||||
|
||||
// BGPPeer is a minimal stand-in until SQL-backed bgp_peer is wired into the API.
|
||||
// BGPPeer maps to bgp_peer (+ display fields in meta).
|
||||
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)
|
||||
ID string
|
||||
TenantID string
|
||||
SpeakerID *string
|
||||
Name string
|
||||
Neighbor string
|
||||
RemoteASN int64
|
||||
Enabled bool
|
||||
SessionState string
|
||||
PoliciesJSON string
|
||||
}
|
||||
|
||||
type Speaker struct {
|
||||
@@ -93,6 +106,7 @@ type Speaker struct {
|
||||
Role string
|
||||
Endpoint string
|
||||
LastAppliedRevisionID *string
|
||||
MetaJSON string
|
||||
}
|
||||
|
||||
func NewMemory() *Memory {
|
||||
@@ -103,6 +117,14 @@ func NewMemory() *Memory {
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +215,8 @@ protocol direct {
|
||||
SpeakerID: &sid,
|
||||
Name: "demo-upstream-4",
|
||||
Neighbor: "198.51.100.2",
|
||||
RemoteASN: 65001,
|
||||
Enabled: true,
|
||||
SessionState: "Established",
|
||||
}
|
||||
p2 := uuid.NewString()
|
||||
@@ -202,10 +226,20 @@ protocol direct {
|
||||
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, Name: "demo-comm", Kind: "large", 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.
|
||||
@@ -256,7 +290,7 @@ func (m *Memory) ListModules(tenantID string) []*Module {
|
||||
defer m.mu.RUnlock()
|
||||
var out []*Module
|
||||
for _, mod := range m.modules {
|
||||
if mod.TenantID == tenantID {
|
||||
if mod.TenantID == tenantID && mod.DeletedAt == nil {
|
||||
out = append(out, mod)
|
||||
}
|
||||
}
|
||||
@@ -287,7 +321,7 @@ func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
mod, ok := m.modules[moduleID]
|
||||
if !ok {
|
||||
if !ok || mod.DeletedAt != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if mod.TenantID != tenantID {
|
||||
@@ -388,6 +422,11 @@ func (m *Memory) CreateRollbackRevision(tenantID, sourceRevisionID string) (newI
|
||||
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
|
||||
}
|
||||
|
||||
@@ -423,8 +462,10 @@ func (m *Memory) PublishRevisionForSpeaker(speakerID, revisionID string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// RevisionDiff returns a trivial JSON-friendly diff between prefix sets (empty for memory demo).
|
||||
// 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
|
||||
@@ -433,13 +474,37 @@ func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error)
|
||||
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": []string{},
|
||||
"removed": []string{},
|
||||
"unchanged_count": 0,
|
||||
"added": added,
|
||||
"removed": removed,
|
||||
"unchanged_count": unchanged,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,798 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var _ Backend = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) CreateModule(tenantID string, in *Module) (*Module, error) {
|
||||
if in == nil || strings.TrimSpace(in.Type) == "" || strings.TrimSpace(in.Name) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
id := uuid.NewString()
|
||||
mod := &Module{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Type: in.Type,
|
||||
Name: strings.TrimSpace(in.Name),
|
||||
Enabled: in.Enabled,
|
||||
Priority: in.Priority,
|
||||
RefreshIntervalSec: in.RefreshIntervalSec,
|
||||
CronExpr: in.CronExpr,
|
||||
DefaultCommunityID: in.DefaultCommunityID,
|
||||
DohProfileID: in.DohProfileID,
|
||||
}
|
||||
m.modules[id] = mod
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateModule(tenantID, moduleID string, patch *ModulePatch) (*Module, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mod, ok := m.modules[moduleID]
|
||||
if !ok || mod.DeletedAt != nil || mod.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Name != nil {
|
||||
mod.Name = strings.TrimSpace(*patch.Name)
|
||||
}
|
||||
if patch.Enabled != nil {
|
||||
mod.Enabled = *patch.Enabled
|
||||
}
|
||||
if patch.Priority != nil {
|
||||
mod.Priority = *patch.Priority
|
||||
}
|
||||
if patch.RefreshIntervalSec != nil {
|
||||
mod.RefreshIntervalSec = *patch.RefreshIntervalSec
|
||||
}
|
||||
if patch.CronExpr != nil {
|
||||
mod.CronExpr = *patch.CronExpr
|
||||
}
|
||||
if patch.DefaultCommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.DefaultCommunityID)
|
||||
if v == "" {
|
||||
mod.DefaultCommunityID = nil
|
||||
} else {
|
||||
mod.DefaultCommunityID = &v
|
||||
}
|
||||
}
|
||||
if patch.DohProfileID != nil {
|
||||
v := strings.TrimSpace(*patch.DohProfileID)
|
||||
if v == "" {
|
||||
mod.DohProfileID = nil
|
||||
} else {
|
||||
mod.DohProfileID = &v
|
||||
}
|
||||
}
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
func (m *Memory) SoftDeleteModule(tenantID, moduleID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mod, ok := m.modules[moduleID]
|
||||
if !ok || mod.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
mod.DeletedAt = &now
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) moduleWriteOK(tenantID, moduleID string) (*Module, error) {
|
||||
mod, ok := m.modules[moduleID]
|
||||
if !ok || mod.DeletedAt != nil || mod.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return mod, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListCDNSources(tenantID, moduleID string) ([]*CDNSource, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "CDN_CIDRS" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
var out []*CDNSource
|
||||
for _, s := range m.cdnSources {
|
||||
if s.ModuleID == moduleID {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateCDNSource(tenantID, moduleID string, in *CDNSource) (*CDNSource, error) {
|
||||
if in == nil || strings.TrimSpace(in.URL) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "CDN_CIDRS" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
id := uuid.NewString()
|
||||
s := &CDNSource{
|
||||
ID: id,
|
||||
ModuleID: moduleID,
|
||||
SourceKind: in.SourceKind,
|
||||
URL: strings.TrimSpace(in.URL),
|
||||
Etag: in.Etag,
|
||||
RefreshIntervalSec: in.RefreshIntervalSec,
|
||||
CommunityID: in.CommunityID,
|
||||
}
|
||||
m.cdnSources[id] = s
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *CDNSourcePatch) (*CDNSource, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s, ok := m.cdnSources[sourceID]
|
||||
if !ok || s.ModuleID != moduleID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.SourceKind != nil {
|
||||
s.SourceKind = *patch.SourceKind
|
||||
}
|
||||
if patch.URL != nil {
|
||||
s.URL = strings.TrimSpace(*patch.URL)
|
||||
}
|
||||
if patch.Etag != nil {
|
||||
s.Etag = *patch.Etag
|
||||
}
|
||||
if patch.RefreshIntervalSec != nil {
|
||||
s.RefreshIntervalSec = patch.RefreshIntervalSec
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
s.CommunityID = nil
|
||||
} else {
|
||||
s.CommunityID = &v
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteCDNSource(tenantID, moduleID, sourceID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
s, ok := m.cdnSources[sourceID]
|
||||
if !ok || s.ModuleID != moduleID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.cdnSources, sourceID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListASEntries(tenantID, moduleID string) ([]*ASEntry, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "AS_PREFIXES" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
var out []*ASEntry
|
||||
for _, e := range m.asEntries {
|
||||
if e.ModuleID == moduleID {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateASEntry(tenantID, moduleID string, in *ASEntry) (*ASEntry, error) {
|
||||
if in == nil || (in.ASN == nil && (in.Prefix == nil || strings.TrimSpace(*in.Prefix) == "")) {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "AS_PREFIXES" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
id := uuid.NewString()
|
||||
e := &ASEntry{ID: id, ModuleID: moduleID, ASN: in.ASN, Prefix: in.Prefix, CommunityID: in.CommunityID}
|
||||
m.asEntries[id] = e
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateASEntry(tenantID, moduleID, entryID string, patch *ASEntryPatch) (*ASEntry, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, ok := m.asEntries[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.ASN != nil {
|
||||
e.ASN = patch.ASN
|
||||
}
|
||||
if patch.Prefix != nil {
|
||||
p := strings.TrimSpace(*patch.Prefix)
|
||||
if p == "" {
|
||||
e.Prefix = nil
|
||||
} else {
|
||||
e.Prefix = &p
|
||||
}
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
e.CommunityID = nil
|
||||
} else {
|
||||
e.CommunityID = &v
|
||||
}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
e, ok := m.asEntries[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.asEntries, entryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListDomainEntries(tenantID, moduleID string) ([]*DomainEntry, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "DOMAINS" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
var out []*DomainEntry
|
||||
for _, e := range m.domainEnt {
|
||||
if e.ModuleID == moduleID {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateDomainEntry(tenantID, moduleID string, in *DomainEntry) (*DomainEntry, error) {
|
||||
if in == nil || strings.TrimSpace(in.FQDN) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "DOMAINS" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
id := uuid.NewString()
|
||||
e := &DomainEntry{ID: id, ModuleID: moduleID, FQDN: strings.TrimSpace(in.FQDN), CommunityID: in.CommunityID}
|
||||
m.domainEnt[id] = e
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *DomainEntryPatch) (*DomainEntry, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, ok := m.domainEnt[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.FQDN != nil {
|
||||
e.FQDN = strings.TrimSpace(*patch.FQDN)
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
e.CommunityID = nil
|
||||
} else {
|
||||
e.CommunityID = &v
|
||||
}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteDomainEntry(tenantID, moduleID, entryID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
e, ok := m.domainEnt[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.domainEnt, entryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListIPRangeEntries(tenantID, moduleID string) ([]*IPRangeEntry, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "IP_RANGES" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
var out []*IPRangeEntry
|
||||
for _, e := range m.ipRanges {
|
||||
if e.ModuleID == moduleID {
|
||||
out = append(out, e)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateIPRangeEntry(tenantID, moduleID string, in *IPRangeEntry) (*IPRangeEntry, error) {
|
||||
if in == nil || strings.TrimSpace(in.Prefix) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
mod, err := m.moduleWriteOK(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "IP_RANGES" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
id := uuid.NewString()
|
||||
e := &IPRangeEntry{ID: id, ModuleID: moduleID, Prefix: strings.TrimSpace(in.Prefix), CommunityID: in.CommunityID}
|
||||
m.ipRanges[id] = e
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *IPRangePatch) (*IPRangeEntry, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, ok := m.ipRanges[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Prefix != nil {
|
||||
e.Prefix = strings.TrimSpace(*patch.Prefix)
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
e.CommunityID = nil
|
||||
} else {
|
||||
e.CommunityID = &v
|
||||
}
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, err := m.moduleWriteOK(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
e, ok := m.ipRanges[entryID]
|
||||
if !ok || e.ModuleID != moduleID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.ipRanges, entryID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListDohProfiles(tenantID string) ([]*DohProfile, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []*DohProfile
|
||||
for _, p := range m.dohProfiles {
|
||||
if p.TenantID == tenantID {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetDohProfile(tenantID, id string) (*DohProfile, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
p, ok := m.dohProfiles[id]
|
||||
if !ok || p.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateDohProfile(tenantID string, in *DohProfile) (*DohProfile, error) {
|
||||
if in == nil || strings.TrimSpace(in.URL) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
id := uuid.NewString()
|
||||
p := &DohProfile{
|
||||
ID: id,
|
||||
TenantID: tenantID,
|
||||
Name: in.Name,
|
||||
URL: strings.TrimSpace(in.URL),
|
||||
TimeoutMs: in.TimeoutMs,
|
||||
SecretRef: in.SecretRef,
|
||||
}
|
||||
m.dohProfiles[id] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateDohProfile(tenantID, id string, patch *DohProfilePatch) (*DohProfile, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.dohProfiles[id]
|
||||
if !ok || p.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Name != nil {
|
||||
p.Name = *patch.Name
|
||||
}
|
||||
if patch.URL != nil {
|
||||
p.URL = strings.TrimSpace(*patch.URL)
|
||||
}
|
||||
if patch.TimeoutMs != nil {
|
||||
p.TimeoutMs = patch.TimeoutMs
|
||||
}
|
||||
if patch.SecretRef != nil {
|
||||
p.SecretRef = patch.SecretRef
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteDohProfile(tenantID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.dohProfiles[id]
|
||||
if !ok || p.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
for _, mod := range m.modules {
|
||||
if mod.DohProfileID != nil && *mod.DohProfileID == id {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
}
|
||||
delete(m.dohProfiles, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListCommunities(tenantID string) ([]*Community, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
var out []*Community
|
||||
for _, c := range m.communities {
|
||||
if c.TenantID == tenantID {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetCommunity(tenantID, id string) (*Community, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
c, ok := m.communities[id]
|
||||
if !ok || c.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateCommunity(tenantID string, in *Community) (*Community, error) {
|
||||
if in == nil || strings.TrimSpace(in.Kind) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
id := uuid.NewString()
|
||||
vj := in.ValueJSON
|
||||
if strings.TrimSpace(vj) == "" {
|
||||
vj = "{}"
|
||||
}
|
||||
c := &Community{ID: id, TenantID: tenantID, Name: in.Name, Kind: strings.TrimSpace(in.Kind), ValueJSON: vj}
|
||||
m.communities[id] = c
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.communities[id]
|
||||
if !ok || c.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Name != nil {
|
||||
c.Name = *patch.Name
|
||||
}
|
||||
if patch.Kind != nil {
|
||||
c.Kind = strings.TrimSpace(*patch.Kind)
|
||||
}
|
||||
if patch.ValueJSON != nil {
|
||||
c.ValueJSON = *patch.ValueJSON
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeleteCommunity(tenantID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c, ok := m.communities[id]
|
||||
if !ok || c.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
_ = c
|
||||
// weak check: modules default_community
|
||||
for _, mod := range m.modules {
|
||||
if mod.DefaultCommunityID != nil && *mod.DefaultCommunityID == id {
|
||||
return ErrInvalidInput
|
||||
}
|
||||
}
|
||||
delete(m.communities, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetPeer(tenantID, id string) (*BGPPeer, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
p, ok := m.peers[id]
|
||||
if !ok || p.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error) {
|
||||
if in == nil || strings.TrimSpace(in.Neighbor) == "" || in.RemoteASN == 0 {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
id := uuid.NewString()
|
||||
p := &BGPPeer{
|
||||
ID: id, TenantID: tenantID, SpeakerID: in.SpeakerID, Name: in.Name,
|
||||
Neighbor: strings.TrimSpace(in.Neighbor), RemoteASN: in.RemoteASN, Enabled: in.Enabled,
|
||||
SessionState: in.SessionState, PoliciesJSON: in.PoliciesJSON,
|
||||
}
|
||||
if !p.Enabled && p.SessionState == "" {
|
||||
p.Enabled = true
|
||||
}
|
||||
m.peers[id] = p
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdatePeer(tenantID, id string, patch *PeerPatch) (*BGPPeer, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.peers[id]
|
||||
if !ok || p.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Neighbor != nil {
|
||||
p.Neighbor = strings.TrimSpace(*patch.Neighbor)
|
||||
}
|
||||
if patch.RemoteASN != nil {
|
||||
p.RemoteASN = *patch.RemoteASN
|
||||
}
|
||||
if patch.SpeakerID != nil {
|
||||
v := strings.TrimSpace(*patch.SpeakerID)
|
||||
if v == "" {
|
||||
p.SpeakerID = nil
|
||||
} else {
|
||||
p.SpeakerID = &v
|
||||
}
|
||||
}
|
||||
if patch.Enabled != nil {
|
||||
p.Enabled = *patch.Enabled
|
||||
}
|
||||
if patch.Name != nil {
|
||||
p.Name = *patch.Name
|
||||
}
|
||||
if patch.SessionState != nil {
|
||||
p.SessionState = *patch.SessionState
|
||||
}
|
||||
if patch.PoliciesJSON != nil {
|
||||
p.PoliciesJSON = *patch.PoliciesJSON
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (m *Memory) DeletePeer(tenantID, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
p, ok := m.peers[id]
|
||||
if !ok || p.TenantID != tenantID {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(m.peers, id)
|
||||
_ = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateSpeaker(tenantID string, in *Speaker) (*Speaker, error) {
|
||||
if in == nil || strings.TrimSpace(in.Role) == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return nil, ErrTenantScope
|
||||
}
|
||||
id := uuid.NewString()
|
||||
sp := &Speaker{ID: id, TenantID: tenantID, Role: strings.TrimSpace(in.Role), Endpoint: in.Endpoint, MetaJSON: in.MetaJSON}
|
||||
m.speakers[id] = sp
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpdateSpeaker(tenantID, id string, patch *SpeakerPatch) (*Speaker, error) {
|
||||
if patch == nil {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
sp, ok := m.speakers[id]
|
||||
if !ok || sp.TenantID != tenantID {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
if patch.Role != nil {
|
||||
sp.Role = strings.TrimSpace(*patch.Role)
|
||||
}
|
||||
if patch.Endpoint != nil {
|
||||
sp.Endpoint = *patch.Endpoint
|
||||
}
|
||||
if patch.MetaJSON != nil {
|
||||
sp.MetaJSON = *patch.MetaJSON
|
||||
}
|
||||
return sp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]PrefixRow, string, bool) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if _, err := m.getRevisionLocked(tenantID, revisionID); err != nil {
|
||||
return nil, "", false
|
||||
}
|
||||
all := m.revPrefixes[revisionID]
|
||||
off := 0
|
||||
if cursor != "" {
|
||||
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||
off = n
|
||||
}
|
||||
}
|
||||
end := off + limit
|
||||
next := ""
|
||||
more := false
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
} else {
|
||||
more = true
|
||||
next = fmt.Sprintf("%d", end)
|
||||
}
|
||||
if off >= len(all) {
|
||||
return nil, "", false
|
||||
}
|
||||
return all[off:end], next, more
|
||||
}
|
||||
|
||||
func (m *Memory) ListGlobalSettings(tenantID string) (map[string]any, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
if m.settings[tenantID] == nil {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
out := make(map[string]any, len(m.settings[tenantID]))
|
||||
for k, v := range m.settings[tenantID] {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) PatchGlobalSettings(tenantID string, patch map[string]any) error {
|
||||
if patch == nil {
|
||||
return nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.tenants[tenantID]; !ok {
|
||||
return ErrTenantScope
|
||||
}
|
||||
if m.settings[tenantID] == nil {
|
||||
m.settings[tenantID] = make(map[string]any)
|
||||
}
|
||||
for key, val := range patch {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
continue
|
||||
}
|
||||
if val == nil {
|
||||
delete(m.settings[tenantID], key)
|
||||
continue
|
||||
}
|
||||
m.settings[tenantID][key] = val
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user