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,141 @@
|
||||
// Package birddeploy applies revision preview fragments to a shared BIRD volume with parse-check and LKG (plan §13).
|
||||
package birddeploy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Config drives two-phase deploy from a revision in the store.
|
||||
type Config struct {
|
||||
ActiveDir string // e.g. /etc/bird (live config tree)
|
||||
StagingDir string // e.g. /tmp/evobgp-staging (same host/volume as ActiveDir)
|
||||
BirdBin string
|
||||
BirdcBin string
|
||||
Socket string // birdc -s
|
||||
}
|
||||
|
||||
// ApplyRevision writes preview fragments to staging, runs bird -p, swaps into active, runs birdc configure; restores LKG on failure.
|
||||
func ApplyRevision(ctx context.Context, ctl *birdfmt.BirdCtl, rev *store.Revision, cfg Config) error {
|
||||
if rev == nil || len(rev.PreviewFragments) == 0 {
|
||||
return fmt.Errorf("birddeploy: no preview fragments")
|
||||
}
|
||||
active := strings.TrimSpace(cfg.ActiveDir)
|
||||
staging := strings.TrimSpace(cfg.StagingDir)
|
||||
if active == "" || staging == "" {
|
||||
return fmt.Errorf("birddeploy: ActiveDir and StagingDir required")
|
||||
}
|
||||
if err := os.MkdirAll(staging, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(active, "bird.d"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
lkg := filepath.Join(active, ".evobgp_lkg")
|
||||
|
||||
// Phase 1: write staging tree
|
||||
for rel, content := range rev.PreviewFragments {
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
dst := filepath.Join(staging, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dst, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
mainConf := filepath.Join(staging, "bird.conf")
|
||||
if _, err := os.Stat(mainConf); err != nil {
|
||||
return fmt.Errorf("birddeploy: staging missing bird.conf: %w", err)
|
||||
}
|
||||
bc := ctl
|
||||
if bc == nil {
|
||||
bc = &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||
}
|
||||
if err := bc.ParseCheck(ctx, mainConf); err != nil {
|
||||
return fmt.Errorf("birddeploy: bird -p failed: %w", err)
|
||||
}
|
||||
|
||||
// Snapshot LKG (best-effort copy of current active bird.conf + bird.d)
|
||||
_ = os.RemoveAll(lkg)
|
||||
_ = snapshotBirdTree(active, lkg)
|
||||
|
||||
// Phase 2: atomic swap into active
|
||||
for rel, content := range rev.PreviewFragments {
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
dst := filepath.Join(active, rel)
|
||||
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
return err
|
||||
}
|
||||
tmp := dst + ".tmp"
|
||||
if err := os.WriteFile(tmp, []byte(content), 0o644); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
return err
|
||||
}
|
||||
if err := os.Rename(tmp, dst); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := bc.Configure(ctx); err != nil {
|
||||
_ = restoreLKG(lkg, active)
|
||||
_ = bc.Configure(ctx)
|
||||
return fmt.Errorf("birddeploy: birdc configure failed, restored LKG: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func snapshotBirdTree(activeRoot, dstRoot string) error {
|
||||
if err := os.MkdirAll(dstRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
mc := filepath.Join(activeRoot, "bird.conf")
|
||||
if b, err := os.ReadFile(mc); err == nil {
|
||||
_ = os.WriteFile(filepath.Join(dstRoot, "bird.conf"), b, 0o644)
|
||||
}
|
||||
bd := filepath.Join(activeRoot, "bird.d")
|
||||
if st, err := os.Stat(bd); err == nil && st.IsDir() {
|
||||
_ = filepath.WalkDir(bd, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
suffix, _ := filepath.Rel(activeRoot, path)
|
||||
out := filepath.Join(dstRoot, suffix)
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(out, b, 0o644)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func restoreLKG(lkg, active string) error {
|
||||
if _, err := os.Stat(filepath.Join(lkg, "bird.conf")); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = filepath.WalkDir(lkg, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil || d.IsDir() {
|
||||
return err
|
||||
}
|
||||
suffix, _ := filepath.Rel(lkg, path)
|
||||
out := filepath.Join(active, suffix)
|
||||
_ = os.MkdirAll(filepath.Dir(out), 0o755)
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(out, b, 0o644)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package birdfmt
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -37,11 +36,12 @@ func TestRenderExportFilterIPv6(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := os.ReadFile("testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(got) != strings.TrimSpace(string(want)) {
|
||||
t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, string(want))
|
||||
want := `filter evobgp_export_v6 {
|
||||
if net ~ [ 2001:db8::/32 ] then accept;
|
||||
reject;
|
||||
}
|
||||
`
|
||||
if strings.TrimSpace(got) != strings.TrimSpace(want) {
|
||||
t.Fatalf("mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestRenderMainBirdConf_Preamble(t *testing.T) {
|
||||
if !strings.HasPrefix(got, "# operator note\n") {
|
||||
t.Fatalf("expected preamble prefix, got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "# # already commented") {
|
||||
if !strings.Contains(got, "# already commented") {
|
||||
t.Fatal(got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
// Package broker stubs reference-profile message broker wiring (NATS JetStream / Redis; plan §2).
|
||||
package broker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// LogConnect logs a one-shot connection attempt when EVOBGP_BROKER_URL is set (full consumer/producer TBD).
|
||||
func LogConnect(ctx context.Context, brokerURL string) {
|
||||
u := strings.TrimSpace(brokerURL)
|
||||
if u == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("broker: EVOBGP_BROKER_URL=%q (stub: no JetStream/Streams consumer in this binary yet)", u)
|
||||
_ = ctx
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// ApplyPostgresMigrations runs ordered *.up.sql from an embedded subtree (idempotent via schema_migrations).
|
||||
func ApplyPostgresMigrations(ctx context.Context, pool *pgxpool.Pool, fsys fs.FS, subdir string) error {
|
||||
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
return fmt.Errorf("db: schema_migrations: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(fsys, subdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ups []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
continue
|
||||
}
|
||||
ups = append(ups, e.Name())
|
||||
}
|
||||
sort.Strings(ups)
|
||||
for _, name := range ups {
|
||||
ver := strings.TrimSuffix(name, ".up.sql")
|
||||
var dummy int
|
||||
err := pool.QueryRow(ctx, `SELECT 1 FROM schema_migrations WHERE version = $1`, ver).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, path.Join(subdir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := pool.Exec(ctx, string(body)); err != nil {
|
||||
return fmt.Errorf("db: migrate %s: %w", name, err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, ver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"evobgp/migrations"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// OpenPostgresPool connects, applies embedded migrations, returns a pool.
|
||||
func OpenPostgresPool(ctx context.Context, dsn string) (*pgxpool.Pool, error) {
|
||||
cfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ApplyPostgresMigrations(ctx, pool, migrations.Postgres, "postgres"); err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// OpenSQLite applies sqlite/*.up.sql then opens the DB (foreign keys on).
|
||||
func OpenSQLite(ctx context.Context, filePath string) (*sql.DB, error) {
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=foreign_keys(1)", strings.TrimPrefix(filePath, "file:"))
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := db.PingContext(ctx); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := applySQLiteMigrations(ctx, db, migrations.SQLite, "sqlite"); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func applySQLiteMigrations(ctx context.Context, db *sql.DB, fsys fs.FS, subdir string) error {
|
||||
if _, err := db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT NOT NULL PRIMARY KEY)`); err != nil {
|
||||
return fmt.Errorf("db: sqlite schema_migrations: %w", err)
|
||||
}
|
||||
entries, err := fs.ReadDir(fsys, subdir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ups []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
continue
|
||||
}
|
||||
ups = append(ups, e.Name())
|
||||
}
|
||||
sort.Strings(ups)
|
||||
for _, name := range ups {
|
||||
ver := strings.TrimSuffix(name, ".up.sql")
|
||||
var dummy int
|
||||
err := db.QueryRowContext(ctx, `SELECT 1 FROM schema_migrations WHERE version = ?`, ver).Scan(&dummy)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
body, err := fs.ReadFile(fsys, path.Join(subdir, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, string(body)); err != nil {
|
||||
// Idempotent ALTER ADD COLUMN for sqlite dev re-runs
|
||||
if strings.Contains(err.Error(), "duplicate column") {
|
||||
_, _ = db.ExecContext(ctx, `INSERT OR IGNORE INTO schema_migrations (version) VALUES (?)`, ver)
|
||||
continue
|
||||
}
|
||||
return fmt.Errorf("db: sqlite migrate %s: %w", name, err)
|
||||
}
|
||||
if _, err := db.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES (?)`, ver); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+11
-1
@@ -4,14 +4,24 @@ package deploy
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/broker"
|
||||
"evobgp/internal/config"
|
||||
)
|
||||
|
||||
// Run blocks until ctx is cancelled. Reference deployment: worker after render, talks to agent API or shared volume.
|
||||
func Run(ctx context.Context) {
|
||||
cfg := config.Load()
|
||||
broker.LogConnect(ctx, cfg.BrokerURL)
|
||||
if d := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR")); d != "" {
|
||||
log.Printf("evobgp-deploy: EVOBGP_BIRD_ACTIVE_DIR=%q (apply handled by API jobs + birddeploy when set on API)", d)
|
||||
}
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
log.Printf("evobgp-deploy: started (stub; deploy jobs push configs and update bundle pointers)")
|
||||
log.Printf("evobgp-deploy: started (orchestration stub; two-phase apply runs in evobgp-api/evobgp-all job worker when EVOBGP_BIRD_ACTIVE_DIR is set)")
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -61,6 +62,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/revisions/latest", s.handleNodeLatestRevision)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}/bundle/{revision_id}", s.handleNodeBundle)
|
||||
m.HandleFunc("POST /nodes/enroll", s.handleNodeEnroll)
|
||||
s.registerCRUDRoutes(m)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -68,7 +70,20 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": map[string]string{"memory_store": "ok"}})
|
||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||
defer cancel()
|
||||
if s.pgPool != nil {
|
||||
if err := s.pgPool.Ping(ctx); err != nil {
|
||||
checks["postgres"] = err.Error()
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
checks["postgres"] = "ok"
|
||||
} else {
|
||||
checks["store_backend"] = "memory"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"status": "ready", "checks": checks})
|
||||
}
|
||||
|
||||
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -107,6 +122,8 @@ func peerJSON(p *store.BGPPeer) map[string]any {
|
||||
"id": p.ID,
|
||||
"name": p.Name,
|
||||
"neighbor": p.Neighbor,
|
||||
"remote_asn": p.RemoteASN,
|
||||
"enabled": p.Enabled,
|
||||
"session_state": p.SessionState,
|
||||
}
|
||||
if p.SpeakerID != nil {
|
||||
|
||||
@@ -0,0 +1,793 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
func (s *Server) registerCRUDRoutes(m *http.ServeMux) {
|
||||
m.HandleFunc("POST /modules", s.handlePostModule)
|
||||
m.HandleFunc("PATCH /modules/{module_id}", s.handlePatchModule)
|
||||
m.HandleFunc("DELETE /modules/{module_id}", s.handleDeleteModule)
|
||||
|
||||
m.HandleFunc("GET /modules/{module_id}/cdn-sources", s.handleListCDNSources)
|
||||
m.HandleFunc("POST /modules/{module_id}/cdn-sources", s.handlePostCDNSource)
|
||||
m.HandleFunc("PATCH /modules/{module_id}/cdn-sources/{source_id}", s.handlePatchCDNSource)
|
||||
m.HandleFunc("DELETE /modules/{module_id}/cdn-sources/{source_id}", s.handleDeleteCDNSource)
|
||||
|
||||
m.HandleFunc("GET /modules/{module_id}/as-entries", s.handleListAS)
|
||||
m.HandleFunc("POST /modules/{module_id}/as-entries", s.handlePostAS)
|
||||
m.HandleFunc("PATCH /modules/{module_id}/as-entries/{entry_id}", s.handlePatchAS)
|
||||
m.HandleFunc("DELETE /modules/{module_id}/as-entries/{entry_id}", s.handleDeleteAS)
|
||||
|
||||
m.HandleFunc("GET /modules/{module_id}/domain-entries", s.handleListDomain)
|
||||
m.HandleFunc("POST /modules/{module_id}/domain-entries", s.handlePostDomain)
|
||||
m.HandleFunc("PATCH /modules/{module_id}/domain-entries/{entry_id}", s.handlePatchDomain)
|
||||
m.HandleFunc("DELETE /modules/{module_id}/domain-entries/{entry_id}", s.handleDeleteDomain)
|
||||
|
||||
m.HandleFunc("GET /modules/{module_id}/ip-range-entries", s.handleListIPRange)
|
||||
m.HandleFunc("POST /modules/{module_id}/ip-range-entries", s.handlePostIPRange)
|
||||
m.HandleFunc("PATCH /modules/{module_id}/ip-range-entries/{entry_id}", s.handlePatchIPRange)
|
||||
m.HandleFunc("DELETE /modules/{module_id}/ip-range-entries/{entry_id}", s.handleDeleteIPRange)
|
||||
|
||||
m.HandleFunc("GET /doh-profiles", s.handleListDoh)
|
||||
m.HandleFunc("POST /doh-profiles", s.handlePostDoh)
|
||||
m.HandleFunc("GET /doh-profiles/{id}", s.handleGetDoh)
|
||||
m.HandleFunc("PATCH /doh-profiles/{id}", s.handlePatchDoh)
|
||||
m.HandleFunc("DELETE /doh-profiles/{id}", s.handleDeleteDoh)
|
||||
|
||||
m.HandleFunc("GET /communities", s.handleListComm)
|
||||
m.HandleFunc("POST /communities", s.handlePostComm)
|
||||
m.HandleFunc("GET /communities/{id}", s.handleGetComm)
|
||||
m.HandleFunc("PATCH /communities/{id}", s.handlePatchComm)
|
||||
m.HandleFunc("DELETE /communities/{id}", s.handleDeleteComm)
|
||||
|
||||
m.HandleFunc("POST /peers", s.handlePostPeer)
|
||||
m.HandleFunc("GET /peers/{id}", s.handleGetPeer)
|
||||
m.HandleFunc("PATCH /peers/{id}", s.handlePatchPeer)
|
||||
m.HandleFunc("DELETE /peers/{id}", s.handleDeletePeer)
|
||||
|
||||
m.HandleFunc("POST /speakers", s.handlePostSpeaker)
|
||||
m.HandleFunc("GET /speakers/{speaker_id}", s.handleGetSpeakerByID)
|
||||
m.HandleFunc("PATCH /speakers/{speaker_id}", s.handlePatchSpeaker)
|
||||
|
||||
m.HandleFunc("GET /revisions/{revision_id}/prefixes", s.handleRevisionPrefixes)
|
||||
|
||||
m.HandleFunc("GET /settings", s.handleGetSettings)
|
||||
m.HandleFunc("PATCH /settings", s.handlePatchSettings)
|
||||
}
|
||||
|
||||
func (s *Server) handlePostModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Priority int `json:"priority"`
|
||||
RefreshIntervalSec int `json:"refresh_interval_sec"`
|
||||
CronExpr string `json:"cron_expr"`
|
||||
DefaultCommunityID *string `json:"default_community_id"`
|
||||
DohProfileID *string `json:"doh_profile_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
mod, err := s.store.CreateModule(a.TenantID, &store.Module{
|
||||
Type: body.Type, Name: body.Name, Enabled: body.Enabled, Priority: body.Priority,
|
||||
RefreshIntervalSec: body.RefreshIntervalSec, CronExpr: body.CronExpr,
|
||||
DefaultCommunityID: body.DefaultCommunityID, DohProfileID: body.DohProfileID,
|
||||
})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, moduleJSON(mod))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.ModulePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
mod, err := s.store.UpdateModule(a.TenantID, r.PathValue("module_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteModule(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.SoftDeleteModule(a.TenantID, r.PathValue("module_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", err.Error())
|
||||
return
|
||||
}
|
||||
if err == store.ErrInvalidInput {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
return
|
||||
}
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
}
|
||||
|
||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListCDNSources(a.TenantID, r.PathValue("module_id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, cdnSourceJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": nil, "has_more": false})
|
||||
}
|
||||
|
||||
func cdnSourceJSON(x *store.CDNSource) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "source_kind": x.SourceKind, "url": x.URL, "etag": x.Etag}
|
||||
if x.RefreshIntervalSec != nil {
|
||||
m["refresh_interval_sec"] = *x.RefreshIntervalSec
|
||||
} else {
|
||||
m["refresh_interval_sec"] = nil
|
||||
}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handlePostCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.CDNSource
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateCDNSource(a.TenantID, r.PathValue("module_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, cdnSourceJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.CDNSourcePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateCDNSource(a.TenantID, r.PathValue("module_id"), r.PathValue("source_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, cdnSourceJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteCDNSource(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCDNSource(a.TenantID, r.PathValue("module_id"), r.PathValue("source_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleListAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListASEntries(a.TenantID, r.PathValue("module_id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, asEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func asEntryJSON(x *store.ASEntry) map[string]any {
|
||||
m := map[string]any{"id": x.ID}
|
||||
if x.ASN != nil {
|
||||
m["asn"] = *x.ASN
|
||||
} else {
|
||||
m["asn"] = nil
|
||||
}
|
||||
if x.Prefix != nil {
|
||||
m["prefix"] = *x.Prefix
|
||||
} else {
|
||||
m["prefix"] = nil
|
||||
}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handlePostAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.ASEntry
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateASEntry(a.TenantID, r.PathValue("module_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, asEntryJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.ASEntryPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateASEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, asEntryJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteAS(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteASEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleListDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListDomainEntries(a.TenantID, r.PathValue("module_id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, domainEntryJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func domainEntryJSON(x *store.DomainEntry) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "fqdn": x.FQDN}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handlePostDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.DomainEntry
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateDomainEntry(a.TenantID, r.PathValue("module_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, domainEntryJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.DomainEntryPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateDomainEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, domainEntryJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDomain(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDomainEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleListIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListIPRangeEntries(a.TenantID, r.PathValue("module_id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, ipRangeJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func ipRangeJSON(x *store.IPRangeEntry) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "prefix": x.Prefix}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.IPRangeEntry
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateIPRangeEntry(a.TenantID, r.PathValue("module_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, ipRangeJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.IPRangePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateIPRangeEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, ipRangeJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteIPRangeEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleListDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListDohProfiles(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, dohJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func dohJSON(x *store.DohProfile) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "name": x.Name, "url": x.URL}
|
||||
if x.TimeoutMs != nil {
|
||||
m["timeout_ms"] = *x.TimeoutMs
|
||||
} else {
|
||||
m["timeout_ms"] = nil
|
||||
}
|
||||
if x.SecretRef != nil {
|
||||
m["vault_secret_ref"] = *x.SecretRef
|
||||
} else {
|
||||
m["vault_secret_ref"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handleGetDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetDohProfile(a.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dohJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePostDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.DohProfile
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateDohProfile(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, dohJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.DohProfilePatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateDohProfile(a.TenantID, r.PathValue("id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, dohJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteDoh(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteDohProfile(a.TenantID, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleListComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
list, err := s.store.ListCommunities(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
items := make([]map[string]any, 0, len(list))
|
||||
for _, x := range list {
|
||||
items = append(items, commJSON(x))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func commJSON(x *store.Community) map[string]any {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(x.ValueJSON), &v); err != nil {
|
||||
v = x.ValueJSON
|
||||
}
|
||||
return map[string]any{"id": x.ID, "name": x.Name, "kind": x.Kind, "value_json": v}
|
||||
}
|
||||
|
||||
func (s *Server) handleGetComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetCommunity(a.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, commJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePostComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.Community
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateCommunity(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, commJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.CommunityPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateCommunity(a.TenantID, r.PathValue("id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, commJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeleteComm(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeleteCommunity(a.TenantID, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handlePostPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.BGPPeer
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
body.TenantID = a.TenantID
|
||||
x, err := s.store.CreatePeer(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, peerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetPeer(a.TenantID, r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, peerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchPeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.PeerPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdatePeer(a.TenantID, r.PathValue("id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, peerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleDeletePeer(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
if err := s.store.DeletePeer(a.TenantID, r.PathValue("id")); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handlePostSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.Speaker
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.CreateSpeaker(a.TenantID, &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, speakerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSpeakerByID(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
x, err := s.store.GetSpeaker(a.TenantID, r.PathValue("speaker_id"))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchSpeaker(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "editor") {
|
||||
return
|
||||
}
|
||||
var body store.SpeakerPatch
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
x, err := s.store.UpdateSpeaker(a.TenantID, r.PathValue("speaker_id"), &body)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, speakerJSON(x))
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionPrefixes(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit == 0 {
|
||||
limit = 50
|
||||
}
|
||||
cursor := r.URL.Query().Get("cursor")
|
||||
rows, next, more := s.store.ListRevisionPrefixes(a.TenantID, r.PathValue("revision_id"), cursor, limit)
|
||||
items := make([]map[string]any, 0, len(rows))
|
||||
for _, pr := range rows {
|
||||
m := map[string]any{"prefix": pr.Prefix, "source": pr.Source}
|
||||
if pr.CommunityID != nil {
|
||||
m["community_id"] = *pr.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
items = append(items, m)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"items": items, "next_cursor": strPtrOrNull(next), "has_more": more})
|
||||
}
|
||||
|
||||
func (s *Server) handleGetSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
m, err := s.store.ListGlobalSettings(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, m)
|
||||
}
|
||||
|
||||
func (s *Server) handlePatchSettings(w http.ResponseWriter, r *http.Request) {
|
||||
a, ok := authFromContext(r.Context())
|
||||
if !ok || !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json")
|
||||
return
|
||||
}
|
||||
if err := s.store.PatchGlobalSettings(a.TenantID, body); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
+48
-14
@@ -1,21 +1,28 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/db"
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/repository"
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Server implements EvoBGP control-plane HTTP API (subset focused on jobs, deploy, node bundle).
|
||||
// Server implements EvoBGP control-plane HTTP API.
|
||||
type Server struct {
|
||||
store *store.Memory
|
||||
store store.Backend
|
||||
pgPool *pgxpool.Pool
|
||||
jobs *jobs.Registry
|
||||
bundlePriv ed25519.PrivateKey
|
||||
apiKeys []apiKeyRecord
|
||||
@@ -26,24 +33,43 @@ type Server struct {
|
||||
|
||||
// Options configures the API server.
|
||||
type Options struct {
|
||||
// APIKeys is comma-separated "token|tenantUUID|role" (role: viewer, editor, operator, node).
|
||||
APIKeys string
|
||||
// InsecureDev with SeedDemo allows Bearer "dev" as operator for the demo tenant (local only).
|
||||
// DatabaseURL enables PostgreSQL-backed store (migrations applied on connect).
|
||||
DatabaseURL string
|
||||
InsecureDev bool
|
||||
SeedDemo bool
|
||||
// BundleSeedHex is 64 hex chars (32 bytes) for deterministic Ed25519 bundle signing; if empty, random.
|
||||
BundleSeedHex string
|
||||
// CORSAllowedOrigins is comma-separated list of allowed browser Origins (e.g. http://localhost:4173).
|
||||
CORSAllowedOrigins string
|
||||
}
|
||||
|
||||
// New constructs Server and wiring for async jobs.
|
||||
func New(opts Options) (*Server, error) {
|
||||
mem := store.NewMemory()
|
||||
if opts.SeedDemo {
|
||||
mem.SeedDemo()
|
||||
var backend store.Backend
|
||||
var pool *pgxpool.Pool
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if u := strings.TrimSpace(opts.DatabaseURL); u != "" {
|
||||
p, err := db.OpenPostgresPool(ctx, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pool = p
|
||||
pgbe, err := repository.NewPostgres(ctx, p, opts.SeedDemo)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
return nil, err
|
||||
}
|
||||
backend = pgbe
|
||||
} else {
|
||||
mem := store.NewMemory()
|
||||
if opts.SeedDemo {
|
||||
mem.SeedDemo()
|
||||
}
|
||||
backend = mem
|
||||
}
|
||||
wk := &jobs.Worker{Store: mem}
|
||||
|
||||
wk := &jobs.Worker{Store: backend}
|
||||
reg := jobs.NewRegistry(wk.Process)
|
||||
|
||||
var priv ed25519.PrivateKey
|
||||
@@ -61,18 +87,26 @@ func New(opts Options) (*Server, error) {
|
||||
}
|
||||
|
||||
s := &Server{
|
||||
store: mem,
|
||||
store: backend,
|
||||
pgPool: pool,
|
||||
jobs: reg,
|
||||
bundlePriv: priv,
|
||||
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
||||
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
}
|
||||
observability.RegisterStoreMetrics(mem)
|
||||
observability.RegisterStoreBackend(backend)
|
||||
s.mux = http.NewServeMux()
|
||||
s.registerRoutes()
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Store exposes the in-memory store (for operators / tests).
|
||||
func (s *Server) Store() *store.Memory { return s.store }
|
||||
// Close releases database resources.
|
||||
func (s *Server) Close() {
|
||||
if s.pgPool != nil {
|
||||
s.pgPool.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Store exposes the backing store (for operators / tests).
|
||||
func (s *Server) Store() store.Backend { return s.store }
|
||||
|
||||
@@ -26,6 +26,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
|
||||
|
||||
@@ -35,6 +36,15 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
base := ts.URL
|
||||
|
||||
t.Run("prometheus metrics", func(t *testing.T) {
|
||||
// HTTPMiddleware increments the counter after the handler returns, so the scrape
|
||||
// of /metrics does not include that same request; warm with a public route first.
|
||||
warm, _ := http.NewRequest(http.MethodGet, base+"/v1/health", nil)
|
||||
warmResp, err := client.Do(warm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _ = io.Copy(io.Discard, warmResp.Body)
|
||||
_ = warmResp.Body.Close()
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,10 +5,15 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/broker"
|
||||
"evobgp/internal/config"
|
||||
)
|
||||
|
||||
// Run blocks until ctx is cancelled. Reference deployment: separate process consuming the job queue.
|
||||
func Run(ctx context.Context) {
|
||||
cfg := config.Load()
|
||||
broker.LogConnect(ctx, cfg.BrokerURL)
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
log.Printf("evobgp-ingest: started (stub; ingest workers dequeue from broker or job_audit)")
|
||||
|
||||
+49
-6
@@ -1,6 +1,12 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/birddeploy"
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
@@ -12,9 +18,9 @@ const (
|
||||
KindBirdReload = "bird_reload"
|
||||
)
|
||||
|
||||
// Worker executes queued jobs against an in-memory store (stub for full render/deploy pipeline).
|
||||
// Worker executes queued jobs against store.Backend (memory or SQL).
|
||||
type Worker struct {
|
||||
Store *store.Memory
|
||||
Store store.Backend
|
||||
}
|
||||
|
||||
// Process is registered as Registry.workerStart.
|
||||
@@ -42,6 +48,19 @@ func (w *Worker) Process(j *Job) {
|
||||
case KindRevisionRollback:
|
||||
w.runRollback(j)
|
||||
case KindBirdReload:
|
||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||
if sock == "" {
|
||||
j.Succeed()
|
||||
return
|
||||
}
|
||||
ctl := &birdfmt.BirdCtl{
|
||||
Socket: sock,
|
||||
Birdc: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
}
|
||||
if err := ctl.Configure(context.Background()); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
j.Succeed()
|
||||
default:
|
||||
j.Fail("unknown job kind")
|
||||
@@ -49,18 +68,42 @@ func (w *Worker) Process(j *Job) {
|
||||
}
|
||||
|
||||
func (w *Worker) runDeployApply(j *Job) {
|
||||
rev, _ := j.Meta["revision_id"].(string)
|
||||
revID, _ := j.Meta["revision_id"].(string)
|
||||
spk, hasSpeaker := j.Meta["speaker_id"].(string)
|
||||
if rev == "" {
|
||||
if revID == "" {
|
||||
j.Fail("missing revision_id in job meta")
|
||||
return
|
||||
}
|
||||
activeDir := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_ACTIVE_DIR"))
|
||||
if activeDir != "" {
|
||||
revObj, err := w.Store.GetRevision(j.TenantID, revID)
|
||||
if err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
staging := strings.TrimSpace(os.Getenv("EVOBGP_BIRD_STAGING_DIR"))
|
||||
if staging == "" {
|
||||
staging = os.TempDir() + "/evobgp-bird-staging"
|
||||
}
|
||||
cfg := birddeploy.Config{
|
||||
ActiveDir: activeDir,
|
||||
StagingDir: staging,
|
||||
BirdBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRD_BIN")),
|
||||
BirdcBin: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN")),
|
||||
Socket: strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET")),
|
||||
}
|
||||
ctl := &birdfmt.BirdCtl{Bird: cfg.BirdBin, Birdc: cfg.BirdcBin, Socket: cfg.Socket}
|
||||
if err := birddeploy.ApplyRevision(context.Background(), ctl, revObj, cfg); err != nil {
|
||||
j.Fail(err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
applyOne := func(speakerID string) error {
|
||||
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, rev); err != nil {
|
||||
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, revID); err != nil {
|
||||
return err
|
||||
}
|
||||
// Replica / node pulls use LatestPublishedRevision; keep pointer in sync with successful deploy.
|
||||
if err := w.Store.PublishRevisionForSpeaker(speakerID, rev); err != nil {
|
||||
if err := w.Store.PublishRevisionForSpeaker(speakerID, revID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
const namespace = "evobgp"
|
||||
|
||||
var (
|
||||
metricsMem atomic.Pointer[store.Memory]
|
||||
metricsStore atomic.Value // store.Backend
|
||||
registerCollectorOnce sync.Once
|
||||
)
|
||||
|
||||
@@ -122,15 +122,19 @@ func (c *memoryStoreCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||
}
|
||||
|
||||
func (c *memoryStoreCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
mem := metricsMem.Load()
|
||||
if mem == nil {
|
||||
v := metricsStore.Load()
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
maxN, sumN := mem.MaterializedPrefixStats()
|
||||
b, ok := v.(store.Backend)
|
||||
if !ok || b == nil {
|
||||
return
|
||||
}
|
||||
maxN, sumN := b.MaterializedPrefixStats()
|
||||
ch <- prometheus.MustNewConstMetric(c.prefixMaxDesc, prometheus.GaugeValue, float64(maxN))
|
||||
ch <- prometheus.MustNewConstMetric(c.prefixSumDesc, prometheus.GaugeValue, float64(sumN))
|
||||
ch <- prometheus.MustNewConstMetric(c.peersDesc, prometheus.GaugeValue, float64(mem.PeerCount()))
|
||||
for state, n := range mem.PeerSessionCountsByState() {
|
||||
ch <- prometheus.MustNewConstMetric(c.peersDesc, prometheus.GaugeValue, float64(b.PeerCount()))
|
||||
for state, n := range b.PeerSessionCountsByState() {
|
||||
if state == "" {
|
||||
state = "unknown"
|
||||
}
|
||||
@@ -138,15 +142,23 @@ func (c *memoryStoreCollector) Collect(ch chan<- prometheus.Metric) {
|
||||
}
|
||||
}
|
||||
|
||||
// RegisterStoreMetrics points Prometheus collectors at the given store (last call wins; safe for tests).
|
||||
// RegisterStoreBackend points Prometheus collectors at any store.Backend (last call wins; safe for tests).
|
||||
func RegisterStoreBackend(b store.Backend) {
|
||||
if b == nil {
|
||||
return
|
||||
}
|
||||
metricsStore.Store(b)
|
||||
registerCollectorOnce.Do(func() {
|
||||
prometheus.DefaultRegisterer.MustRegister(newMemoryStoreCollector())
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterStoreMetrics is a deprecated alias for RegisterStoreBackend (memory-only callers).
|
||||
func RegisterStoreMetrics(mem *store.Memory) {
|
||||
if mem == nil {
|
||||
return
|
||||
}
|
||||
metricsMem.Store(mem)
|
||||
registerCollectorOnce.Do(func() {
|
||||
prometheus.DefaultRegisterer.MustRegister(newMemoryStoreCollector())
|
||||
})
|
||||
RegisterStoreBackend(mem)
|
||||
}
|
||||
|
||||
// SetBirdSessionMetrics updates gauges from an optional birdc scrape.
|
||||
|
||||
@@ -5,10 +5,15 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/broker"
|
||||
"evobgp/internal/config"
|
||||
)
|
||||
|
||||
// Run blocks until ctx is cancelled. Reference deployment: worker process after ingest completes.
|
||||
func Run(ctx context.Context) {
|
||||
cfg := config.Load()
|
||||
broker.LogConnect(ctx, cfg.BrokerURL)
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
log.Printf("evobgp-render: started (stub; render jobs produce revisions and artifacts)")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,560 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/store"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
func (p *Postgres) ListCDNSources(tenantID, moduleID string) ([]*store.CDNSource, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "CDN_CIDRS" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id::text, source_kind, url, COALESCE(etag,''), refresh_interval_sec, community_id::text
|
||||
FROM module_cdn_source WHERE module_id=$1 ORDER BY url`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.CDNSource
|
||||
for rows.Next() {
|
||||
var s store.CDNSource
|
||||
s.ModuleID = moduleID
|
||||
var ri *int32
|
||||
var comm *string
|
||||
if err := rows.Scan(&s.ID, &s.SourceKind, &s.URL, &s.Etag, &ri, &comm); err != nil {
|
||||
continue
|
||||
}
|
||||
if ri != nil {
|
||||
v := int(*ri)
|
||||
s.RefreshIntervalSec = &v
|
||||
}
|
||||
s.CommunityID = strOrNil(comm)
|
||||
out = append(out, &s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateCDNSource(tenantID, moduleID string, in *store.CDNSource) (*store.CDNSource, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "CDN_CIDRS" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
if in == nil || strings.TrimSpace(in.URL) == "" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
id := uuid.NewString()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
INSERT INTO module_cdn_source (id, module_id, source_kind, url, etag, refresh_interval_sec, community_id)
|
||||
VALUES ($1,$2,$3,$4,$5,$6, NULLIF($7::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
||||
id, moduleID, in.SourceKind, strings.TrimSpace(in.URL), in.Etag, nullInt32Ptr(in.RefreshIntervalSec), uuidOrNilPtr(in.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getCDNSource(ctx, moduleID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) getCDNSource(ctx context.Context, moduleID, id string) (*store.CDNSource, error) {
|
||||
var s store.CDNSource
|
||||
s.ModuleID = moduleID
|
||||
var ri *int32
|
||||
var comm *string
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, source_kind, url, COALESCE(etag,''), refresh_interval_sec, community_id::text
|
||||
FROM module_cdn_source WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&s.ID, &s.SourceKind, &s.URL, &s.Etag, &ri, &comm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ri != nil {
|
||||
v := int(*ri)
|
||||
s.RefreshIntervalSec = &v
|
||||
}
|
||||
s.CommunityID = strOrNil(comm)
|
||||
return &s, nil
|
||||
}
|
||||
|
||||
func uuidOrNilPtr(s *string) any {
|
||||
if s == nil || strings.TrimSpace(*s) == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.TrimSpace(*s)
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateCDNSource(tenantID, moduleID, sourceID string, patch *store.CDNSourcePatch) (*store.CDNSource, error) {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err := p.getCDNSource(context.Background(), moduleID, sourceID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if patch.SourceKind != nil {
|
||||
cur.SourceKind = *patch.SourceKind
|
||||
}
|
||||
if patch.URL != nil {
|
||||
cur.URL = strings.TrimSpace(*patch.URL)
|
||||
}
|
||||
if patch.Etag != nil {
|
||||
cur.Etag = *patch.Etag
|
||||
}
|
||||
if patch.RefreshIntervalSec != nil {
|
||||
cur.RefreshIntervalSec = patch.RefreshIntervalSec
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
cur.CommunityID = nil
|
||||
} else {
|
||||
cur.CommunityID = &v
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
UPDATE module_cdn_source SET source_kind=$3, url=$4, etag=$5, refresh_interval_sec=$6,
|
||||
community_id=NULLIF($7::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
sourceID, moduleID, cur.SourceKind, cur.URL, cur.Etag, nullInt32Ptr(cur.RefreshIntervalSec), uuidOrNilPtr(cur.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getCDNSource(ctx, moduleID, sourceID)
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteCDNSource(tenantID, moduleID, sourceID string) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM module_cdn_source WHERE id=$1 AND module_id=$2`, sourceID, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListASEntries(tenantID, moduleID string) ([]*store.ASEntry, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "AS_PREFIXES" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `
|
||||
SELECT id::text, asn, prefix::text, community_id::text FROM module_as_entry WHERE module_id=$1`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.ASEntry
|
||||
for rows.Next() {
|
||||
var e store.ASEntry
|
||||
e.ModuleID = moduleID
|
||||
var asn *int64
|
||||
var pref, comm *string
|
||||
if err := rows.Scan(&e.ID, &asn, &pref, &comm); err != nil {
|
||||
continue
|
||||
}
|
||||
e.ASN = asn
|
||||
e.Prefix = pref
|
||||
e.CommunityID = strOrNil(comm)
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateASEntry(tenantID, moduleID string, in *store.ASEntry) (*store.ASEntry, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "AS_PREFIXES" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
if in == nil || (in.ASN == nil && (in.Prefix == nil || strings.TrimSpace(*in.Prefix) == "")) {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
id := uuid.NewString()
|
||||
var pref any
|
||||
if in.Prefix != nil && strings.TrimSpace(*in.Prefix) != "" {
|
||||
pref = strings.TrimSpace(*in.Prefix)
|
||||
}
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
INSERT INTO module_as_entry (id, module_id, asn, prefix, community_id)
|
||||
VALUES ($1,$2,$3,$4::cidr, NULLIF($5::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
||||
id, moduleID, in.ASN, pref, uuidOrNilPtr(in.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getASEntry(ctx, moduleID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) getASEntry(ctx context.Context, moduleID, id string) (*store.ASEntry, error) {
|
||||
var e store.ASEntry
|
||||
e.ModuleID = moduleID
|
||||
var asn *int64
|
||||
var pref, comm *string
|
||||
err := p.pool.QueryRow(ctx, `
|
||||
SELECT id::text, asn, prefix::text, community_id::text FROM module_as_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(
|
||||
&e.ID, &asn, &pref, &comm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.ASN = asn
|
||||
e.Prefix = pref
|
||||
e.CommunityID = strOrNil(comm)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateASEntry(tenantID, moduleID, entryID string, patch *store.ASEntryPatch) (*store.ASEntry, error) {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err := p.getASEntry(context.Background(), moduleID, entryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if patch.ASN != nil {
|
||||
cur.ASN = patch.ASN
|
||||
}
|
||||
if patch.Prefix != nil {
|
||||
p := strings.TrimSpace(*patch.Prefix)
|
||||
if p == "" {
|
||||
cur.Prefix = nil
|
||||
} else {
|
||||
cur.Prefix = &p
|
||||
}
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
cur.CommunityID = nil
|
||||
} else {
|
||||
cur.CommunityID = &v
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
var pref any
|
||||
if cur.Prefix != nil {
|
||||
pref = *cur.Prefix
|
||||
}
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
UPDATE module_as_entry SET asn=$3, prefix=$4::cidr, community_id=NULLIF($5::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
entryID, moduleID, cur.ASN, pref, uuidOrNilPtr(cur.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getASEntry(ctx, moduleID, entryID)
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteASEntry(tenantID, moduleID, entryID string) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM module_as_entry WHERE id=$1 AND module_id=$2`, entryID, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListDomainEntries(tenantID, moduleID string) ([]*store.DomainEntry, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "DOMAINS" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `SELECT id::text, fqdn, community_id::text FROM module_domain_entry WHERE module_id=$1`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.DomainEntry
|
||||
for rows.Next() {
|
||||
var e store.DomainEntry
|
||||
e.ModuleID = moduleID
|
||||
var comm *string
|
||||
if err := rows.Scan(&e.ID, &e.FQDN, &comm); err != nil {
|
||||
continue
|
||||
}
|
||||
e.CommunityID = strOrNil(comm)
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateDomainEntry(tenantID, moduleID string, in *store.DomainEntry) (*store.DomainEntry, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "DOMAINS" || in == nil || strings.TrimSpace(in.FQDN) == "" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
id := uuid.NewString()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
INSERT INTO module_domain_entry (id, module_id, fqdn, community_id)
|
||||
VALUES ($1,$2,$3, NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
||||
id, moduleID, strings.TrimSpace(in.FQDN), uuidOrNilPtr(in.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getDomainEntry(ctx, moduleID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) getDomainEntry(ctx context.Context, moduleID, id string) (*store.DomainEntry, error) {
|
||||
var e store.DomainEntry
|
||||
e.ModuleID = moduleID
|
||||
var comm *string
|
||||
err := p.pool.QueryRow(ctx, `SELECT id::text, fqdn, community_id::text FROM module_domain_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&e.ID, &e.FQDN, &comm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CommunityID = strOrNil(comm)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateDomainEntry(tenantID, moduleID, entryID string, patch *store.DomainEntryPatch) (*store.DomainEntry, error) {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err := p.getDomainEntry(context.Background(), moduleID, entryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if patch.FQDN != nil {
|
||||
cur.FQDN = strings.TrimSpace(*patch.FQDN)
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
cur.CommunityID = nil
|
||||
} else {
|
||||
cur.CommunityID = &v
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
UPDATE module_domain_entry SET fqdn=$3, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
entryID, moduleID, cur.FQDN, uuidOrNilPtr(cur.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getDomainEntry(ctx, moduleID, entryID)
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteDomainEntry(tenantID, moduleID, entryID string) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM module_domain_entry WHERE id=$1 AND module_id=$2`, entryID, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListIPRangeEntries(tenantID, moduleID string) ([]*store.IPRangeEntry, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "IP_RANGES" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `SELECT id::text, prefix::text, community_id::text FROM module_ip_range_entry WHERE module_id=$1`, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*store.IPRangeEntry
|
||||
for rows.Next() {
|
||||
var e store.IPRangeEntry
|
||||
e.ModuleID = moduleID
|
||||
var comm *string
|
||||
if err := rows.Scan(&e.ID, &e.Prefix, &comm); err != nil {
|
||||
continue
|
||||
}
|
||||
e.CommunityID = strOrNil(comm)
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateIPRangeEntry(tenantID, moduleID string, in *store.IPRangeEntry) (*store.IPRangeEntry, error) {
|
||||
mod, err := p.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if mod.Type != "IP_RANGES" || in == nil || strings.TrimSpace(in.Prefix) == "" {
|
||||
return nil, store.ErrInvalidInput
|
||||
}
|
||||
ctx := context.Background()
|
||||
id := uuid.NewString()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
INSERT INTO module_ip_range_entry (id, module_id, prefix, community_id)
|
||||
VALUES ($1,$2,$3::cidr, NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid))`,
|
||||
id, moduleID, strings.TrimSpace(in.Prefix), uuidOrNilPtr(in.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getIPRangeEntry(ctx, moduleID, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) getIPRangeEntry(ctx context.Context, moduleID, id string) (*store.IPRangeEntry, error) {
|
||||
var e store.IPRangeEntry
|
||||
e.ModuleID = moduleID
|
||||
var comm *string
|
||||
err := p.pool.QueryRow(ctx, `SELECT id::text, prefix::text, community_id::text FROM module_ip_range_entry WHERE id=$1 AND module_id=$2`, id, moduleID).Scan(&e.ID, &e.Prefix, &comm)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CommunityID = strOrNil(comm)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UpdateIPRangeEntry(tenantID, moduleID, entryID string, patch *store.IPRangePatch) (*store.IPRangeEntry, error) {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cur, err := p.getIPRangeEntry(context.Background(), moduleID, entryID)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if patch.Prefix != nil {
|
||||
cur.Prefix = strings.TrimSpace(*patch.Prefix)
|
||||
}
|
||||
if patch.CommunityID != nil {
|
||||
v := strings.TrimSpace(*patch.CommunityID)
|
||||
if v == "" {
|
||||
cur.CommunityID = nil
|
||||
} else {
|
||||
cur.CommunityID = &v
|
||||
}
|
||||
}
|
||||
ctx := context.Background()
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
UPDATE module_ip_range_entry SET prefix=$3::cidr, community_id=NULLIF($4::uuid, '00000000-0000-0000-0000-000000000000'::uuid), updated_at=now()
|
||||
WHERE id=$1 AND module_id=$2`,
|
||||
entryID, moduleID, cur.Prefix, uuidOrNilPtr(cur.CommunityID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.getIPRangeEntry(ctx, moduleID, entryID)
|
||||
}
|
||||
|
||||
func (p *Postgres) DeleteIPRangeEntry(tenantID, moduleID, entryID string) error {
|
||||
if _, err := p.GetModule(tenantID, moduleID); err != nil {
|
||||
return err
|
||||
}
|
||||
ctx := context.Background()
|
||||
tag, err := p.pool.Exec(ctx, `DELETE FROM module_ip_range_entry WHERE id=$1 AND module_id=$2`, entryID, moduleID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) ListGlobalSettings(tenantID string) (map[string]any, error) {
|
||||
ctx := context.Background()
|
||||
rows, err := p.pool.Query(ctx, `SELECT key, value_json FROM global_settings WHERE tenant_id=$1`, tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]any)
|
||||
for rows.Next() {
|
||||
var k string
|
||||
var vj []byte
|
||||
if err := rows.Scan(&k, &vj); err != nil {
|
||||
continue
|
||||
}
|
||||
var v any
|
||||
_ = json.Unmarshal(vj, &v)
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Postgres) PatchGlobalSettings(tenantID string, patch map[string]any) error {
|
||||
if patch == nil {
|
||||
return nil
|
||||
}
|
||||
ctx := context.Background()
|
||||
for k, v := range patch {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
continue
|
||||
}
|
||||
if v == nil {
|
||||
_, _ = p.pool.Exec(ctx, `DELETE FROM global_settings WHERE tenant_id=$1 AND key=$2`, tenantID, k)
|
||||
continue
|
||||
}
|
||||
b, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = p.pool.Exec(ctx, `
|
||||
INSERT INTO global_settings (tenant_id, key, value_json) VALUES ($1,$2,$3::jsonb)
|
||||
ON CONFLICT (tenant_id, key) DO UPDATE SET value_json = EXCLUDED.value_json, updated_at = now()`,
|
||||
tenantID, k, string(b))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ store.Backend = (*Postgres)(nil)
|
||||
|
||||
var _ store.Backend = (*Postgres)(nil)
|
||||
@@ -0,0 +1,124 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func (p *Postgres) seedDemo(ctx context.Context) error {
|
||||
var n int
|
||||
if err := p.pool.QueryRow(ctx, `SELECT COUNT(*)::int FROM tenant`).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return p.attachDemoIDs(ctx)
|
||||
}
|
||||
|
||||
tid := uuid.NewString()
|
||||
mCDN := uuid.NewString()
|
||||
mIP := uuid.NewString()
|
||||
parent := uuid.NewString()
|
||||
rid := uuid.NewString()
|
||||
sid := uuid.NewString()
|
||||
cid := uuid.NewString()
|
||||
p1 := uuid.NewString()
|
||||
p2 := uuid.NewString()
|
||||
|
||||
preview := map[string]any{
|
||||
"preview_fragments": map[string]string{
|
||||
"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",
|
||||
},
|
||||
"materialized_prefix_count": 128,
|
||||
}
|
||||
previewB, _ := json.Marshal(preview)
|
||||
parentMeta, _ := json.Marshal(map[string]any{
|
||||
"preview_fragments": map[string]string{"bird.conf": "# parent revision\n"},
|
||||
"materialized_prefix_count": 0,
|
||||
})
|
||||
|
||||
tx, err := p.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO tenant (id, name, slug) VALUES ($1,'Demo','demo')`, tid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, name, kind, value_json) VALUES ($1,$2,'demo-comm','large','{}')`, cid, tid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority, refresh_interval_sec)
|
||||
VALUES ($1,$2,'CDN_CIDRS','demo-cdn',true,10,3600)`, mCDN, tid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO module (id, tenant_id, type, name, enabled, priority)
|
||||
VALUES ($1,$2,'IP_RANGES','demo-static',true,20)`, mIP, tid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO config_revision (id, tenant_id, content_hash, parent_revision_id, meta_json)
|
||||
VALUES ($1,$2,'sha256:parent',NULL,$3::jsonb)`, parent, tid, string(parentMeta)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO config_revision (id, tenant_id, content_hash, parent_revision_id, meta_json)
|
||||
VALUES ($1,$2,'sha256:demo-rev-1',$3::uuid,$4::jsonb)`, rid, tid, parent, string(previewB)); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source)
|
||||
VALUES ($1::uuid,'203.0.113.0/24',$2::uuid,'demo'), ($1::uuid,'2001:db8::/32',$2::uuid,'demo')`, rid, cid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO bgp_speaker (id, tenant_id, role, endpoint, published_revision_id, published_at)
|
||||
VALUES ($1,$2,'replica','10.0.0.2:179',$3::uuid, now())`, sid, tid, rid); err != nil {
|
||||
return err
|
||||
}
|
||||
meta4 := `{"name":"demo-upstream-4","session_state":"Established"}`
|
||||
meta6 := `{"name":"demo-upstream-6","session_state":"Idle"}`
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
|
||||
VALUES ($1,$2,$3::uuid,'198.51.100.2'::inet,65001,true,'{}',$4::jsonb)`, p1, tid, sid, meta4); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json)
|
||||
VALUES ($1,$2,$3::uuid,'2001:db8::2'::inet,65002,true,'{}',$4::jsonb)`, p2, tid, sid, meta6); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk = tid, mCDN, mIP, rid, sid
|
||||
return nil
|
||||
}
|
||||
|
||||
// attachDemoIDs fills demo pointer fields when DB already has data (e.g. compose restart).
|
||||
func (p *Postgres) attachDemoIDs(ctx context.Context) error {
|
||||
if err := p.pool.QueryRow(ctx, `SELECT id::text FROM tenant WHERE slug = 'demo' ORDER BY created_at LIMIT 1`).Scan(&p.demoTenant); err != nil {
|
||||
return nil // non-fatal: no demo tenant
|
||||
}
|
||||
_ = p.pool.QueryRow(ctx, `SELECT id::text FROM module WHERE tenant_id = $1::uuid AND name = 'demo-cdn' LIMIT 1`, p.demoTenant).Scan(&p.demoCDN)
|
||||
_ = p.pool.QueryRow(ctx, `SELECT id::text FROM module WHERE tenant_id = $1::uuid AND name = 'demo-static' LIMIT 1`, p.demoTenant).Scan(&p.demoIP)
|
||||
_ = p.pool.QueryRow(ctx, `
|
||||
SELECT id::text FROM config_revision WHERE tenant_id = $1::uuid AND content_hash LIKE 'sha256:demo%' ORDER BY created_at DESC LIMIT 1`, p.demoTenant).Scan(&p.demoRev)
|
||||
_ = p.pool.QueryRow(ctx, `SELECT id::text FROM bgp_speaker WHERE tenant_id = $1::uuid LIMIT 1`, p.demoTenant).Scan(&p.demoSpk)
|
||||
return nil
|
||||
}
|
||||
@@ -6,10 +6,15 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/broker"
|
||||
"evobgp/internal/config"
|
||||
)
|
||||
|
||||
// Run blocks until ctx is cancelled. In production it connects to DB + broker and schedules refresh events.
|
||||
func Run(ctx context.Context) {
|
||||
cfg := config.Load()
|
||||
broker.LogConnect(ctx, cfg.BrokerURL)
|
||||
t := time.NewTicker(60 * time.Second)
|
||||
defer t.Stop()
|
||||
log.Printf("evobgp-scheduler: started (stub; connect EVOBGP_DATABASE_URL / EVOBGP_BROKER_URL for full stack)")
|
||||
|
||||
@@ -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