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.
CI / changes (push) Successful in 5s
CI / go (push) Successful in 1m40s
CI / openapi (push) Has been skipped
CI / bird2 (push) Successful in 17s

This commit is contained in:
Denozordec
2026-04-05 17:03:21 +07:00
parent bf52b21150
commit 6a55f72ab3
35 changed files with 4609 additions and 193 deletions
+18
View File
@@ -4,6 +4,7 @@ import (
"context"
"flag"
"fmt"
"log"
"os"
"time"
@@ -15,11 +16,13 @@ func main() {
birdc := flag.String("birdc", "", "path to birdc binary (default: birdc from PATH)")
socket := flag.String("socket", "", "optional birdc control socket (-s)")
timeout := flag.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
watchEvery := flag.Duration("watch-interval", 30*time.Second, "for watch: interval between birdc configure")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " parse-check <path/to/bird.conf> run bird -c <path> -p (syntax check)\n")
fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n")
fmt.Fprintf(os.Stderr, " watch periodically run birdc configure (compose sidecar)\n")
flag.PrintDefaults()
}
flag.Parse()
@@ -59,6 +62,21 @@ func main() {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "watch":
if *watchEvery <= 0 {
fmt.Fprintln(os.Stderr, "watch-interval must be > 0")
os.Exit(2)
}
log.Printf("evobgp-agent watch: birdc configure every %s (socket=%q)", *watchEvery, *socket)
for {
cctx, cancel := context.WithTimeout(context.Background(), *timeout)
err := ctl.Configure(cctx)
cancel()
if err != nil {
log.Printf("evobgp-agent watch: configure: %v", err)
}
time.Sleep(*watchEvery)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
flag.Usage()
+2
View File
@@ -26,6 +26,7 @@ func main() {
cfg := config.Load()
opts := httpapi.Options{
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
DatabaseURL: cfg.DatabaseURL,
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
@@ -35,6 +36,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
defer srv.Close()
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
+7 -5
View File
@@ -21,16 +21,18 @@ func main() {
cfg := config.Load()
seedDemo := os.Getenv("EVOBGP_SEED_DEMO") != "0"
opts := httpapi.Options{
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
SeedDemo: seedDemo,
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
DatabaseURL: cfg.DatabaseURL,
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
SeedDemo: seedDemo,
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
}
srv, err := httpapi.New(opts)
if err != nil {
log.Fatal(err)
}
defer srv.Close()
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
startBirdMetricsPoller()
+133 -3
View File
@@ -4,6 +4,12 @@
# docker compose --profile reference up -d --build
# docker compose --profile microvps up -d --build
#
# reference: 5× Go (api + scheduler + ingest + render + deploy) + postgres + NATS JetStream + bird2 + agent + web.
# microvps: evobgp-all + postgres + bird2 + agent (без брокера).
#
# Опция microVPS_sqlite из плана (без контейнера Postgres): требует реализации store на SQLite в приложении;
# пока используйте microvps с контейнерным Postgres (единые миграции).
#
# Профиль reference: Web UI на http://localhost:3000 (nginx → /v1 и /metrics на evobgp-api:8080).
#
# Очистка неиспользуемых данных Docker (PowerShell; осторожно с -v):
@@ -20,6 +26,13 @@ x-logging: &default-logging
max-size: "10m"
max-file: "3"
x-env-ref: &env-ref
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
EVOBGP_BROKER_URL: nats://nats:4222
x-env-micro: &env-micro
EVOBGP_DATABASE_URL: postgres://evobgp:evobgp@postgres:5432/evobgp?sslmode=disable
services:
postgres:
profiles: ["reference", "microvps"]
@@ -43,6 +56,19 @@ services:
cpus: "1.0"
memory: 384M
nats:
profiles: ["reference"]
image: nats:2.10-alpine
restart: unless-stopped
command: ["-js", "-m", "8222"]
ports:
- "4222:4222"
logging: *default-logging
deploy:
resources:
limits:
memory: 256M
evobgp-api:
profiles: ["reference"]
build:
@@ -55,18 +81,23 @@ services:
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
bird2:
condition: service_started
ports:
- "8080:8080"
environment:
<<: *env-ref
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s
# Для прямого доступа к API с dev-сервера Vite (порт 5173); в Docker UI трафик идёт через evobgp-web proxy.
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
EVOBGP_CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird:ro
logging: *default-logging
deploy:
@@ -75,6 +106,101 @@ services:
cpus: "1.0"
memory: 512M
evobgp-scheduler:
profiles: ["reference"]
build:
context: ../..
dockerfile: deploy/docker/gobinary/Dockerfile
args:
BIN: evobgp-scheduler
INSTALL_BIRDC: "0"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
logging: *default-logging
deploy:
resources:
limits:
cpus: "0.25"
memory: 128M
evobgp-ingest:
profiles: ["reference"]
build:
context: ../..
dockerfile: deploy/docker/gobinary/Dockerfile
args:
BIN: evobgp-ingest
INSTALL_BIRDC: "0"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
logging: *default-logging
deploy:
resources:
limits:
cpus: "0.25"
memory: 128M
evobgp-render:
profiles: ["reference"]
build:
context: ../..
dockerfile: deploy/docker/gobinary/Dockerfile
args:
BIN: evobgp-render
INSTALL_BIRDC: "0"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
logging: *default-logging
deploy:
resources:
limits:
cpus: "0.25"
memory: 128M
evobgp-deploy:
profiles: ["reference"]
build:
context: ../..
dockerfile: deploy/docker/gobinary/Dockerfile
args:
BIN: evobgp-deploy
INSTALL_BIRDC: "0"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
nats:
condition: service_started
environment:
<<: *env-ref
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
volumes:
- bird_etc:/etc/bird:ro
logging: *default-logging
deploy:
resources:
limits:
cpus: "0.25"
memory: 128M
evobgp-web:
profiles: ["reference"]
build:
@@ -109,11 +235,15 @@ services:
ports:
- "8080:8080"
environment:
<<: *env-micro
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s
EVOBGP_BIRD_ACTIVE_DIR: /etc/bird
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird:ro
logging: *default-logging
deploy:
@@ -157,8 +287,8 @@ services:
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/bin/sh", "-c"]
command: ["sleep infinity"]
entrypoint: ["/usr/local/bin/evobgp-agent"]
command: ["watch", "-socket=/run/bird/bird.ctl", "-watch-interval=30s"]
logging: *default-logging
deploy:
resources:
+27
View File
@@ -4,5 +4,32 @@ go 1.22
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.2
github.com/prometheus/client_golang v1.20.5
modernc.org/sqlite v1.34.5
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.55.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.31.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
modernc.org/libc v1.55.3 // indirect
modernc.org/mathutil v1.6.0 // indirect
modernc.org/memory v1.8.0 // indirect
)
+59 -33
View File
@@ -2,37 +2,36 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0/go.mod h1:Ty41ER440msb0O0wD5nx4736lXe6Yvz9gof6rHUU8=
github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
@@ -43,26 +42,53 @@ github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs=
golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
modernc.org/sqlite v1.34.5 h1:Bb6SR13/fjp15jt70CL4f18JIN7p7dnMExd+UFnF15g=
modernc.org/sqlite v1.34.5/go.mod h1:YLuNmX9NKs8wRNK2ko1LW1NGYcc9FkBO69JOt1AR9JE=
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+141
View File
@@ -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
}
+7 -7
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
}
}
+18
View File
@@ -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
}
+55
View File
@@ -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
}
+97
View File
@@ -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
View File
@@ -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():
+18 -1
View File
@@ -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 {
+793
View File
@@ -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
View File
@@ -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 }
+10
View File
@@ -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
View File
@@ -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
View File
@@ -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
+23 -11
View File
@@ -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
View File
@@ -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
+560
View File
@@ -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)
+124
View File
@@ -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
}
+5
View File
@@ -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)")
+195
View File
@@ -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
View File
@@ -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
}
+798
View File
@@ -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
}
@@ -0,0 +1,3 @@
ALTER TABLE bgp_speaker
DROP COLUMN IF EXISTS published_at,
DROP COLUMN IF EXISTS published_revision_id;
@@ -0,0 +1,5 @@
-- Published revision pointer for evobgp-node (plan §7.11, §10).
ALTER TABLE bgp_speaker
ADD COLUMN IF NOT EXISTS published_revision_id UUID REFERENCES config_revision (id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
-- SQLite cannot DROP COLUMN easily in older versions; no-op for dev rollback.
SELECT 1;
@@ -0,0 +1,5 @@
-- SQLite: add columns (IF NOT EXISTS per column not supported — use new table copy if needed).
-- pragma: skip if columns exist — simplest: ALTER TABLE ADD COLUMN (SQLite allows multiple ADD).
ALTER TABLE bgp_speaker ADD COLUMN published_revision_id TEXT REFERENCES config_revision (id) ON DELETE SET NULL;
ALTER TABLE bgp_speaker ADD COLUMN published_at TEXT;
+110
View File
@@ -0,0 +1,110 @@
# Resolves module dependency tree via proxy.golang.org and fetches sum.golang.org lines.
# Appends missing entries to go.sum (run from repo root: pwsh -File scripts/expand-go-sum.ps1).
Set-StrictMode -Version 3
$ErrorActionPreference = "Stop"
# Escape module path for sum.golang.org / proxy.golang.org (lowercase + %XX for other UTF-8 bytes).
function Escape-GoModulePath2([string]$path) {
$lower = $path.ToLowerInvariant()
$bytes = [System.Text.Encoding]::UTF8.GetBytes($lower)
$sb = New-Object System.Text.StringBuilder
foreach ($b in $bytes) {
$c = [char]$b
$ok = ($b -ge 48 -and $b -le 57) -or ($b -ge 97 -and $b -le 122) -or ($c -eq '-') -or ($c -eq '.') -or ($c -eq '_') -or ($c -eq '+')
if ($ok) {
[void]$sb.Append($c)
}
else {
[void]$sb.AppendFormat("%{0:x2}", $b)
}
}
return $sb.ToString()
}
function Get-SumdbLines([string]$modPath, [string]$ver) {
$enc = Escape-GoModulePath2 $modPath
$url = "https://sum.golang.org/lookup/$enc@$ver"
$raw = (Invoke-WebRequest -Uri $url -UseBasicParsing).Content
$out = New-Object System.Collections.Generic.List[string]
foreach ($line in ($raw -split "`n")) {
$t = $line.Trim()
if ($t -match '^\S+\s+v\S+\s+h1:') {
[void]$out.Add($t)
}
}
return $out
}
function Get-GoMod([string]$modPath, [string]$ver) {
$enc = Escape-GoModulePath2 $modPath
$url = "https://proxy.golang.org/$enc/@v/$ver.mod"
return (Invoke-WebRequest -Uri $url -UseBasicParsing).Content
}
function Parse-Requires([string]$modText) {
$req = @()
$inBlock = $false
foreach ($line in ($modText -split "`n")) {
$t = $line.Trim()
if ($t.StartsWith("//")) { continue }
if ($t -match '^require\s+\(\s*$') { $inBlock = $true; continue }
if ($inBlock) {
if ($t -eq ")") { $inBlock = $false; continue }
if ($t -match '^(\S+)\s+(v[^\s]+)') {
$req += ,@($Matches[1], $Matches[2])
}
continue
}
if ($t -match '^require\s+(\S+)\s+(v[^\s]+)\s*(//.*)?$') {
$req += ,@($Matches[1], $Matches[2])
}
}
return $req
}
$root = Split-Path -Parent $PSScriptRoot
Set-Location $root
$queue = [System.Collections.Queue]::new()
$seen = @{}
$allLines = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($line in Get-Content -Path (Join-Path $root "go.sum")) {
if ($line.Trim()) { [void]$allLines.Add($line.Trim()) }
}
function Enqueue([string]$p, [string]$v) {
if ([string]::IsNullOrWhiteSpace($p) -or [string]::IsNullOrWhiteSpace($v)) { return }
if ($p -notmatch '\.') { return }
$k = "$p@$v"
if ($seen.ContainsKey($k)) { return }
$seen[$k] = $true
$queue.Enqueue($k)
}
# Seed from go.mod direct requires (edit if go.mod changes)
Enqueue "github.com/google/uuid" "v1.6.0"
Enqueue "github.com/jackc/pgx/v5" "v5.7.2"
Enqueue "github.com/prometheus/client_golang" "v1.20.5"
Enqueue "modernc.org/sqlite" "v1.34.5"
while ($queue.Count -gt 0) {
$item = $queue.Dequeue()
$p, $v = $item.Split("@", 2)
foreach ($ln in (Get-SumdbLines $p $v)) {
[void]$allLines.Add($ln)
}
$modBody = Get-GoMod $p $v
foreach ($pair in (Parse-Requires $modBody)) {
Enqueue $pair[0] $pair[1]
}
}
$ordered = @($allLines | Sort-Object)
$pathSum = Join-Path $root "go.sum"
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllLines($pathSum, $ordered, $utf8NoBom)
Write-Host "go.sum written: $($ordered.Count) lines"
+182 -95
View File
@@ -8,7 +8,9 @@
"name": "web",
"version": "0.0.1",
"dependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^1.7.0",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
@@ -37,7 +39,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -54,7 +55,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -71,7 +71,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -88,7 +87,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -105,7 +103,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -122,7 +119,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -139,7 +135,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -156,7 +151,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -173,7 +167,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -190,7 +183,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -207,7 +199,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -224,7 +215,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -241,7 +231,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -258,7 +247,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -275,7 +263,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -292,7 +279,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -309,7 +295,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -326,7 +311,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -343,7 +327,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -360,7 +343,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -377,7 +359,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -394,7 +375,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -411,7 +391,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -428,7 +407,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -445,7 +423,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -462,7 +439,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -472,6 +448,40 @@
"node": ">=18"
}
},
"node_modules/@floating-ui/core": {
"version": "1.7.5",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz",
"integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.7.6",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz",
"integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.5",
"@floating-ui/utils": "^0.2.11"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.11",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz",
"integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==",
"license": "MIT"
},
"node_modules/@internationalized/date": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.12.0.tgz",
"integrity": "sha512-/PyIMzK29jtXaGU23qTvNZxvBXRtKbNnGDFD+PY6CZw/Y8Ex8pFUzkuCJCG9aOqmShjqhS9mPqP6Dk5onQY8rQ==",
"license": "Apache-2.0",
"dependencies": {
"@swc/helpers": "^0.5.0"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -530,7 +540,7 @@
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
@@ -540,7 +550,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -554,7 +563,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -568,7 +576,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -582,7 +589,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -596,7 +602,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -610,7 +615,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -624,7 +628,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -638,7 +641,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -652,7 +654,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -666,7 +667,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -680,7 +680,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -694,7 +693,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -708,7 +706,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -722,7 +719,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -736,7 +732,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -750,7 +745,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -764,7 +758,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -778,7 +771,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -792,7 +784,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -806,7 +797,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -820,7 +810,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -834,7 +823,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -848,7 +836,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -862,7 +849,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -876,7 +862,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -887,7 +872,7 @@
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
@@ -913,7 +898,7 @@
"version": "2.56.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.56.1.tgz",
"integrity": "sha512-9hDOl3yUh8UXWt+mN29dbcdrW0vNwPvMqi01y2Mw+ceErNIISh8MeEY7fXT2Dx1CjC/kfsVqrbxw7DifYr4hsg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
@@ -955,7 +940,7 @@
"version": "6.2.4",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-6.2.4.tgz",
"integrity": "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@sveltejs/vite-plugin-svelte-inspector": "^5.0.0",
@@ -976,7 +961,7 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-5.0.2.tgz",
"integrity": "sha512-TZzRTcEtZffICSAoZGkPSl6Etsj2torOVrx6Uw0KpXxrec9Gg6jFWQ60Q3+LmNGfZSxHRCZL7vXVZIWmuV50Ig==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"obug": "^2.1.0"
@@ -990,6 +975,15 @@
"vite": "^6.3.0 || ^7.0.0"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.21.tgz",
"integrity": "sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@tailwindcss/node": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
@@ -1266,7 +1260,7 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/estree": {
@@ -1324,6 +1318,30 @@
"node": ">= 0.4"
}
},
"node_modules/bits-ui": {
"version": "2.17.2",
"resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.17.2.tgz",
"integrity": "sha512-Xbmrlf3ft/5RtMgUL/uam+rcrWYM4LoIGosklV34iSE6GGsPFWgaivzzX60rnWjWlG5F7ZuXXZ4iag949UekMA==",
"license": "MIT",
"dependencies": {
"@floating-ui/core": "^1.7.1",
"@floating-ui/dom": "^1.7.1",
"esm-env": "^1.1.2",
"runed": "^0.35.1",
"svelte-toolbelt": "^0.10.6",
"tabbable": "^6.2.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sponsors/huntabyte"
},
"peerDependencies": {
"@internationalized/date": "^3.8.1",
"svelte": "^5.33.0"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -1353,7 +1371,7 @@
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
@@ -1363,17 +1381,26 @@
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1403,7 +1430,7 @@
"version": "0.27.7",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz",
"integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -1461,7 +1488,7 @@
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
@@ -1479,7 +1506,6 @@
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
@@ -1497,6 +1523,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/inline-style-parser": {
"version": "0.2.7",
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
"license": "MIT"
},
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -1510,7 +1542,7 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
"integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "lib/jiti-cli.mjs"
@@ -1520,7 +1552,7 @@
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -1530,7 +1562,7 @@
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
"dev": true,
"devOptional": true,
"license": "MPL-2.0",
"dependencies": {
"detect-libc": "^2.0.3"
@@ -1563,7 +1595,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1584,7 +1615,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1605,7 +1635,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1626,7 +1655,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1647,7 +1675,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1668,7 +1695,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1689,7 +1715,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1710,7 +1735,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1731,7 +1755,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1752,7 +1775,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1773,7 +1795,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -1793,6 +1814,15 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"license": "MIT"
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"license": "MIT",
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -1816,7 +1846,7 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1826,7 +1856,7 @@
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "github",
@@ -1845,7 +1875,7 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"devOptional": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
@@ -1856,14 +1886,14 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -1876,7 +1906,7 @@
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"devOptional": true,
"funding": [
{
"type": "opencollective",
@@ -2025,7 +2055,7 @@
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
"integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@types/estree": "1.0.8"
@@ -2066,6 +2096,30 @@
"fsevents": "~2.3.2"
}
},
"node_modules/runed": {
"version": "0.35.1",
"resolved": "https://registry.npmjs.org/runed/-/runed-0.35.1.tgz",
"integrity": "sha512-2F4Q/FZzbeJTFdIS/PuOoPRSm92sA2LhzTnv6FXhCoENb3huf5+fDuNOg1LNvGOouy3u/225qxmuJvcV3IZK5Q==",
"funding": [
"https://github.com/sponsors/huntabyte",
"https://github.com/sponsors/tglide"
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3",
"esm-env": "^1.0.0",
"lz-string": "^1.5.0"
},
"peerDependencies": {
"@sveltejs/kit": "^2.21.0",
"svelte": "^5.7.0"
},
"peerDependenciesMeta": {
"@sveltejs/kit": {
"optional": true
}
}
},
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -2083,14 +2137,14 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
@@ -2105,12 +2159,21 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/style-to-object": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
"license": "MIT",
"dependencies": {
"inline-style-parser": "0.2.7"
}
},
"node_modules/svelte": {
"version": "5.55.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.1.tgz",
@@ -2190,6 +2253,32 @@
"svelte": "^5.7.0"
}
},
"node_modules/svelte-toolbelt": {
"version": "0.10.6",
"resolved": "https://registry.npmjs.org/svelte-toolbelt/-/svelte-toolbelt-0.10.6.tgz",
"integrity": "sha512-YWuX+RE+CnWYx09yseAe4ZVMM7e7GRFZM6OYWpBKOb++s+SQ8RBIMMe+Bs/CznBMc0QPLjr+vDBxTAkozXsFXQ==",
"funding": [
"https://github.com/sponsors/huntabyte"
],
"dependencies": {
"clsx": "^2.1.1",
"runed": "^0.35.1",
"style-to-object": "^1.0.8"
},
"engines": {
"node": ">=18",
"pnpm": ">=8.7.0"
},
"peerDependencies": {
"svelte": "^5.30.2"
}
},
"node_modules/tabbable": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz",
"integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==",
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
@@ -2243,7 +2332,7 @@
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
"integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
@@ -2260,7 +2349,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -2270,9 +2359,7 @@
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true,
"license": "0BSD",
"optional": true
"license": "0BSD"
},
"node_modules/tw-animate-css": {
"version": "1.4.0",
@@ -2287,7 +2374,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -2301,7 +2388,7 @@
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "^0.27.0",
@@ -2376,7 +2463,7 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
"integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
+2
View File
@@ -28,7 +28,9 @@
"vite": "^7.3.1"
},
"dependencies": {
"@internationalized/date": "^3.8.1",
"@lucide/svelte": "^1.7.0",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",