diff --git a/cmd/evobgp-agent/main.go b/cmd/evobgp-agent/main.go index f3c9c8a..0ba0837 100644 --- a/cmd/evobgp-agent/main.go +++ b/cmd/evobgp-agent/main.go @@ -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] \n", os.Args[0]) fmt.Fprintf(os.Stderr, "Commands:\n") fmt.Fprintf(os.Stderr, " parse-check run bird -c -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() diff --git a/cmd/evobgp-all/main.go b/cmd/evobgp-all/main.go index 6427be9..b02cc02 100644 --- a/cmd/evobgp-all/main.go +++ b/cmd/evobgp-all/main.go @@ -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) diff --git a/cmd/evobgp-api/main.go b/cmd/evobgp-api/main.go index 2816155..5622846 100644 --- a/cmd/evobgp-api/main.go +++ b/cmd/evobgp-api/main.go @@ -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() diff --git a/deploy/compose/docker-compose.yaml b/deploy/compose/docker-compose.yaml index 8566967..8620921 100644 --- a/deploy/compose/docker-compose.yaml +++ b/deploy/compose/docker-compose.yaml @@ -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: diff --git a/go.mod b/go.mod index a62c458..f6523f9 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 65c4bf2..f81e26f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/birddeploy/apply.go b/internal/birddeploy/apply.go new file mode 100644 index 0000000..68e3915 --- /dev/null +++ b/internal/birddeploy/apply.go @@ -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 +} diff --git a/internal/birdfmt/filter_test.go b/internal/birdfmt/filter_test.go index 0f9d81c..6b326da 100644 --- a/internal/birdfmt/filter_test.go +++ b/internal/birdfmt/filter_test.go @@ -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) } } diff --git a/internal/birdfmt/layout_test.go b/internal/birdfmt/layout_test.go index 6bf2d44..20f4892 100644 --- a/internal/birdfmt/layout_test.go +++ b/internal/birdfmt/layout_test.go @@ -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) } } diff --git a/internal/broker/connect.go b/internal/broker/connect.go new file mode 100644 index 0000000..8e0aeed --- /dev/null +++ b/internal/broker/connect.go @@ -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 +} diff --git a/internal/db/apply.go b/internal/db/apply.go new file mode 100644 index 0000000..64c4f2a --- /dev/null +++ b/internal/db/apply.go @@ -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 +} diff --git a/internal/db/open.go b/internal/db/open.go new file mode 100644 index 0000000..326386e --- /dev/null +++ b/internal/db/open.go @@ -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 +} diff --git a/internal/deploy/run.go b/internal/deploy/run.go index 63306ea..508bd24 100644 --- a/internal/deploy/run.go +++ b/internal/deploy/run.go @@ -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(): diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 61f1438..4c93c3c 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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 { diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go new file mode 100644 index 0000000..db3cd47 --- /dev/null +++ b/internal/httpapi/routes_crud.go @@ -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"}) +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 84763a5..58c96ac 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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 } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 4c9b2d0..9f8c19e 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -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 { diff --git a/internal/ingest/run.go b/internal/ingest/run.go index eaf6f62..2641b64 100644 --- a/internal/ingest/run.go +++ b/internal/ingest/run.go @@ -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)") diff --git a/internal/jobs/worker.go b/internal/jobs/worker.go index cdb498f..f921436 100644 --- a/internal/jobs/worker.go +++ b/internal/jobs/worker.go @@ -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 diff --git a/internal/observability/metrics.go b/internal/observability/metrics.go index 28979ee..1d3e71f 100644 --- a/internal/observability/metrics.go +++ b/internal/observability/metrics.go @@ -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. diff --git a/internal/render/run.go b/internal/render/run.go index c7e26a4..bebe30f 100644 --- a/internal/render/run.go +++ b/internal/render/run.go @@ -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)") diff --git a/internal/repository/postgres.go b/internal/repository/postgres.go new file mode 100644 index 0000000..af3dad9 --- /dev/null +++ b/internal/repository/postgres.go @@ -0,0 +1,1010 @@ +// Package repository implements SQL-backed store.Backend (PostgreSQL). +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "evobgp/internal/store" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Postgres implements store.Backend using pgxpool. +type Postgres struct { + pool *pgxpool.Pool + // demo IDs after seed + demoTenant, demoCDN, demoIP, demoRev, demoSpk string +} + +// NewPostgres opens migrations-applied pool is assumed; seedDemo inserts demo tenant graph. +func NewPostgres(ctx context.Context, pool *pgxpool.Pool, seedDemo bool) (*Postgres, error) { + p := &Postgres{pool: pool} + if seedDemo { + if err := p.seedDemo(ctx); err != nil { + return nil, err + } + } + return p, nil +} + +func (p *Postgres) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) { + return p.demoTenant, p.demoCDN, p.demoIP, p.demoRev, p.demoSpk +} + +func (p *Postgres) MaterializedPrefixStats() (max int, sum int) { + ctx := context.Background() + rows, err := p.pool.Query(ctx, ` + SELECT COALESCE((meta_json->>'materialized_prefix_count')::int, 0) AS n + FROM config_revision`) + if err != nil { + return 0, 0 + } + defer rows.Close() + for rows.Next() { + var n int + if rows.Scan(&n) == nil { + sum += n + if n > max { + max = n + } + } + } + return max, sum +} + +func (p *Postgres) PeerCount() int { + ctx := context.Background() + var n int + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM bgp_peer`).Scan(&n) + return n +} + +func (p *Postgres) PeerSessionCountsByState() map[string]int { + ctx := context.Background() + rows, err := p.pool.Query(ctx, `SELECT COALESCE(meta_json->>'session_state','unknown'), COUNT(*) FROM bgp_peer GROUP BY 1`) + if err != nil { + return map[string]int{} + } + defer rows.Close() + out := make(map[string]int) + for rows.Next() { + var st string + var c int + if rows.Scan(&st, &c) == nil { + out[st] = c + } + } + return out +} + +func (p *Postgres) ListModules(tenantID string) []*store.Module { + ctx := context.Background() + rows, err := p.pool.Query(ctx, ` + SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text + FROM module WHERE tenant_id = $1 AND deleted_at IS NULL ORDER BY priority, name`, tenantID) + if err != nil { + return nil + } + defer rows.Close() + var out []*store.Module + for rows.Next() { + var m store.Module + m.TenantID = tenantID + var doh, dc *string + var refresh *int32 + if err := rows.Scan(&m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &m.CronExpr, &dc); err != nil { + continue + } + if refresh != nil { + m.RefreshIntervalSec = int(*refresh) + } + if doh != nil && *doh != "" { + m.DohProfileID = doh + } + if dc != nil && *dc != "" { + m.DefaultCommunityID = dc + } + out = append(out, &m) + } + return out +} + +func (p *Postgres) GetModule(tenantID, moduleID string) (*store.Module, error) { + ctx := context.Background() + var m store.Module + m.TenantID = tenantID + var doh, dc *string + var refresh *int32 + err := p.pool.QueryRow(ctx, ` + SELECT id, type, name, enabled, priority, doh_profile_id::text, refresh_interval_sec, cron_expr, default_community_id::text + FROM module WHERE id = $1 AND tenant_id = $2 AND deleted_at IS NULL`, moduleID, tenantID).Scan( + &m.ID, &m.Type, &m.Name, &m.Enabled, &m.Priority, &doh, &refresh, &m.CronExpr, &dc) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + if refresh != nil { + m.RefreshIntervalSec = int(*refresh) + } + if doh != nil && *doh != "" { + m.DohProfileID = doh + } + if dc != nil && *dc != "" { + m.DefaultCommunityID = dc + } + return &m, nil +} + +func (p *Postgres) CreateModule(tenantID string, in *store.Module) (*store.Module, error) { + if in == nil { + return nil, store.ErrInvalidInput + } + ctx := context.Background() + id := uuid.NewString() + var doh, dc any + if in.DohProfileID != nil && strings.TrimSpace(*in.DohProfileID) != "" { + doh = strings.TrimSpace(*in.DohProfileID) + } + if in.DefaultCommunityID != nil && strings.TrimSpace(*in.DefaultCommunityID) != "" { + dc = strings.TrimSpace(*in.DefaultCommunityID) + } + var ri any + if in.RefreshIntervalSec != 0 { + ri = in.RefreshIntervalSec + } + var cronArg any + if strings.TrimSpace(in.CronExpr) != "" { + cronArg = strings.TrimSpace(in.CronExpr) + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO module (id, tenant_id, type, name, enabled, priority, doh_profile_id, refresh_interval_sec, cron_expr, default_community_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, + id, tenantID, in.Type, in.Name, in.Enabled, in.Priority, doh, ri, cronArg, dc) + if err != nil { + return nil, err + } + return p.GetModule(tenantID, id) +} + +func (p *Postgres) UpdateModule(tenantID, moduleID string, patch *store.ModulePatch) (*store.Module, error) { + if patch == nil { + return nil, store.ErrInvalidInput + } + ctx := context.Background() + base, err := p.GetModule(tenantID, moduleID) + if err != nil { + return nil, err + } + name := base.Name + en := base.Enabled + pr := base.Priority + ri := base.RefreshIntervalSec + cron := base.CronExpr + var dc, doh *string + dc = base.DefaultCommunityID + doh = base.DohProfileID + if patch.Name != nil { + name = strings.TrimSpace(*patch.Name) + } + if patch.Enabled != nil { + en = *patch.Enabled + } + if patch.Priority != nil { + pr = *patch.Priority + } + if patch.RefreshIntervalSec != nil { + ri = *patch.RefreshIntervalSec + } + if patch.CronExpr != nil { + cron = *patch.CronExpr + } + if patch.DefaultCommunityID != nil { + v := strings.TrimSpace(*patch.DefaultCommunityID) + if v == "" { + dc = nil + } else { + dc = &v + } + } + if patch.DohProfileID != nil { + v := strings.TrimSpace(*patch.DohProfileID) + if v == "" { + doh = nil + } else { + doh = &v + } + } + var dcArg, dohArg any + if dc != nil { + dcArg = *dc + } + if doh != nil { + dohArg = *doh + } + var riArg any + if ri != 0 { + riArg = ri + } + var cronArg any + if strings.TrimSpace(cron) != "" { + cronArg = strings.TrimSpace(cron) + } + _, err = p.pool.Exec(ctx, ` + UPDATE module SET name=$3, enabled=$4, priority=$5, refresh_interval_sec=$6, cron_expr=$7, + default_community_id=$8, doh_profile_id=$9, updated_at=now() + WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`, + moduleID, tenantID, name, en, pr, riArg, cronArg, dcArg, dohArg) + if err != nil { + return nil, err + } + return p.GetModule(tenantID, moduleID) +} + +func (p *Postgres) SoftDeleteModule(tenantID, moduleID string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, `UPDATE module SET deleted_at=now(), updated_at=now() WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL`, moduleID, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) ListPeers(tenantID string) []*store.BGPPeer { + ctx := context.Background() + rows, err := p.pool.Query(ctx, ` + SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled, + COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}') + FROM bgp_peer WHERE tenant_id=$1 ORDER BY neighbor`, tenantID) + if err != nil { + return nil + } + defer rows.Close() + var out []*store.BGPPeer + for rows.Next() { + var peer store.BGPPeer + var sp *string + if err := rows.Scan(&peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON); err != nil { + continue + } + peer.SpeakerID = sp + out = append(out, &peer) + } + return out +} + +func (p *Postgres) GetPeer(tenantID, id string) (*store.BGPPeer, error) { + ctx := context.Background() + var peer store.BGPPeer + var sp *string + err := p.pool.QueryRow(ctx, ` + SELECT id::text, tenant_id::text, bgp_speaker_id::text, neighbor::text, remote_asn, enabled, + COALESCE(meta_json->>'name',''), COALESCE(meta_json->>'session_state',''), COALESCE(policies_json::text,'{}') + FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan( + &peer.ID, &peer.TenantID, &sp, &peer.Neighbor, &peer.RemoteASN, &peer.Enabled, &peer.Name, &peer.SessionState, &peer.PoliciesJSON) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + peer.SpeakerID = sp + return &peer, nil +} + +func (p *Postgres) CreatePeer(tenantID string, in *store.BGPPeer) (*store.BGPPeer, error) { + if in == nil || in.RemoteASN == 0 { + return nil, store.ErrInvalidInput + } + ctx := context.Background() + id := uuid.NewString() + meta := map[string]any{"name": in.Name, "session_state": in.SessionState} + mb, _ := json.Marshal(meta) + pol := "{}" + if strings.TrimSpace(in.PoliciesJSON) != "" { + pol = in.PoliciesJSON + } + var sp any + if in.SpeakerID != nil && strings.TrimSpace(*in.SpeakerID) != "" { + sp = strings.TrimSpace(*in.SpeakerID) + } + _, err := p.pool.Exec(ctx, ` + INSERT INTO bgp_peer (id, tenant_id, bgp_speaker_id, neighbor, remote_asn, enabled, policies_json, meta_json) + VALUES ($1,$2,$3,$4::inet, $5, $6, $7::jsonb, $8::jsonb)`, + id, tenantID, sp, in.Neighbor, in.RemoteASN, in.Enabled, pol, string(mb)) + if err != nil { + return nil, err + } + return p.GetPeer(tenantID, id) +} + +func (p *Postgres) UpdatePeer(tenantID, id string, patch *store.PeerPatch) (*store.BGPPeer, error) { + cur, err := p.GetPeer(tenantID, id) + if err != nil { + return nil, err + } + if patch.Neighbor != nil { + cur.Neighbor = strings.TrimSpace(*patch.Neighbor) + } + if patch.RemoteASN != nil { + cur.RemoteASN = *patch.RemoteASN + } + if patch.Enabled != nil { + cur.Enabled = *patch.Enabled + } + if patch.Name != nil { + cur.Name = *patch.Name + } + if patch.SessionState != nil { + cur.SessionState = *patch.SessionState + } + if patch.PoliciesJSON != nil { + cur.PoliciesJSON = *patch.PoliciesJSON + } + if patch.SpeakerID != nil { + v := strings.TrimSpace(*patch.SpeakerID) + if v == "" { + cur.SpeakerID = nil + } else { + cur.SpeakerID = &v + } + } + ctx := context.Background() + meta := map[string]any{"name": cur.Name, "session_state": cur.SessionState} + mb, _ := json.Marshal(meta) + pol := "{}" + if strings.TrimSpace(cur.PoliciesJSON) != "" { + pol = cur.PoliciesJSON + } + var sp any + if cur.SpeakerID != nil && strings.TrimSpace(*cur.SpeakerID) != "" { + sp = strings.TrimSpace(*cur.SpeakerID) + } + _, err = p.pool.Exec(ctx, ` + UPDATE bgp_peer SET neighbor=$3::inet, remote_asn=$4, enabled=$5, policies_json=$6::jsonb, meta_json=$7::jsonb, + bgp_speaker_id=$8, updated_at=now() + WHERE id=$1 AND tenant_id=$2`, id, tenantID, cur.Neighbor, cur.RemoteASN, cur.Enabled, pol, string(mb), sp) + if err != nil { + return nil, err + } + return p.GetPeer(tenantID, id) +} + +func (p *Postgres) DeletePeer(tenantID, id string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_peer WHERE id=$1 AND tenant_id=$2`, id, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) ListSpeakersForTenant(tenantID string) []*store.Speaker { + ctx := context.Background() + rows, err := p.pool.Query(ctx, ` + SELECT id::text, role, COALESCE(endpoint,''), last_applied_revision_id::text, COALESCE(meta_json::text,'{}') + FROM bgp_speaker WHERE tenant_id=$1 ORDER BY id`, tenantID) + if err != nil { + return nil + } + defer rows.Close() + var out []*store.Speaker + for rows.Next() { + var s store.Speaker + s.TenantID = tenantID + var lap *string + if err := rows.Scan(&s.ID, &s.Role, &s.Endpoint, &lap, &s.MetaJSON); err != nil { + continue + } + s.LastAppliedRevisionID = strOrNil(lap) + out = append(out, &s) + } + return out +} + +func (p *Postgres) GetSpeaker(tenantID, speakerID string) (*store.Speaker, error) { + sp, err := p.getSpeakerRow(context.Background(), speakerID) + if err != nil { + return nil, err + } + if sp.TenantID != tenantID { + return nil, store.ErrTenantScope + } + return sp, nil +} + +func (p *Postgres) GetSpeakerAnyTenant(speakerID string) (*store.Speaker, error) { + return p.getSpeakerRow(context.Background(), speakerID) +} + +func (p *Postgres) getSpeakerRow(ctx context.Context, speakerID string) (*store.Speaker, error) { + var s store.Speaker + var lap *string + err := p.pool.QueryRow(ctx, ` + SELECT id::text, tenant_id::text, role, COALESCE(endpoint,''), last_applied_revision_id::text, COALESCE(meta_json::text,'{}') + FROM bgp_speaker WHERE id=$1`, speakerID).Scan(&s.ID, &s.TenantID, &s.Role, &s.Endpoint, &lap, &s.MetaJSON) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + s.LastAppliedRevisionID = strOrNil(lap) + return &s, nil +} + +func (p *Postgres) CreateSpeaker(tenantID string, in *store.Speaker) (*store.Speaker, error) { + if in == nil { + return nil, store.ErrInvalidInput + } + ctx := context.Background() + id := uuid.NewString() + meta := in.MetaJSON + if meta == "" { + meta = "{}" + } + var ep any + if strings.TrimSpace(in.Endpoint) != "" { + ep = strings.TrimSpace(in.Endpoint) + } + _, err := p.pool.Exec(ctx, `INSERT INTO bgp_speaker (id, tenant_id, role, endpoint, meta_json) VALUES ($1,$2,$3,$4,$5::jsonb)`, + id, tenantID, in.Role, ep, meta) + if err != nil { + return nil, err + } + return p.GetSpeaker(tenantID, id) +} + +func (p *Postgres) UpdateSpeaker(tenantID, id string, patch *store.SpeakerPatch) (*store.Speaker, error) { + cur, err := p.GetSpeaker(tenantID, id) + if err != nil { + return nil, err + } + if patch.Role != nil { + cur.Role = strings.TrimSpace(*patch.Role) + } + if patch.Endpoint != nil { + cur.Endpoint = *patch.Endpoint + } + if patch.MetaJSON != nil { + cur.MetaJSON = *patch.MetaJSON + } + ctx := context.Background() + meta := cur.MetaJSON + if strings.TrimSpace(meta) == "" { + meta = "{}" + } + var ep any + if strings.TrimSpace(cur.Endpoint) != "" { + ep = strings.TrimSpace(cur.Endpoint) + } + _, err = p.pool.Exec(ctx, `UPDATE bgp_speaker SET role=$3, endpoint=$4, meta_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`, + id, tenantID, cur.Role, ep, meta) + if err != nil { + return nil, err + } + return p.GetSpeaker(tenantID, id) +} + +func (p *Postgres) GetRevision(tenantID, revisionID string) (*store.Revision, error) { + ctx := context.Background() + var r store.Revision + var mod *string + var parent *string + var meta []byte + err := p.pool.QueryRow(ctx, ` + SELECT id::text, tenant_id::text, module_id::text, content_hash, parent_revision_id::text, meta_json, created_at + FROM config_revision WHERE id=$1 AND tenant_id=$2`, revisionID, tenantID).Scan( + &r.ID, &r.TenantID, &mod, &r.ContentHash, &parent, &meta, &r.CreatedAt) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + if mod != nil { + r.ModuleID = *mod + } + r.ParentRevisionID = strOrNil(parent) + var mj struct { + PreviewFragments map[string]string `json:"preview_fragments"` + MaterializedPrefixCount int `json:"materialized_prefix_count"` + } + _ = json.Unmarshal(meta, &mj) + if mj.PreviewFragments == nil { + mj.PreviewFragments = map[string]string{} + } + r.PreviewFragments = mj.PreviewFragments + r.MaterializedPrefixCount = mj.MaterializedPrefixCount + return &r, nil +} + +func (p *Postgres) ListRevisions(tenantID, moduleID string, cursor string, limit int) ([]*store.Revision, string, bool) { + if limit <= 0 { + limit = 50 + } + ctx := context.Background() + q := `SELECT id::text, module_id::text, content_hash, parent_revision_id::text, meta_json, created_at FROM config_revision WHERE tenant_id=$1` + args := []any{tenantID} + if moduleID != "" { + q += ` AND module_id = $2` + args = append(args, moduleID) + } + q += ` ORDER BY created_at DESC` + rows, err := p.pool.Query(ctx, q, args...) + if err != nil { + return nil, "", false + } + defer rows.Close() + var all []*store.Revision + for rows.Next() { + var r store.Revision + r.TenantID = tenantID + var mod, parent *string + var meta []byte + if err := rows.Scan(&r.ID, &mod, &r.ContentHash, &parent, &meta, &r.CreatedAt); err != nil { + continue + } + if mod != nil { + r.ModuleID = *mod + } + r.ParentRevisionID = strOrNil(parent) + var mj struct { + PreviewFragments map[string]string `json:"preview_fragments"` + MaterializedPrefixCount int `json:"materialized_prefix_count"` + } + _ = json.Unmarshal(meta, &mj) + if mj.PreviewFragments == nil { + mj.PreviewFragments = map[string]string{} + } + r.PreviewFragments = mj.PreviewFragments + r.MaterializedPrefixCount = mj.MaterializedPrefixCount + all = append(all, &r) + } + off := 0 + if cursor != "" { + if n, err := strconv.Atoi(cursor); err == nil && n >= 0 { + off = n + } + } + end := off + limit + next := "" + hasMore := false + if end > len(all) { + end = len(all) + } else { + hasMore = true + next = fmt.Sprintf("%d", end) + } + if off >= len(all) { + return nil, "", false + } + return all[off:end], next, hasMore +} + +func (p *Postgres) ListRevisionPrefixes(tenantID, revisionID string, cursor string, limit int) ([]store.PrefixRow, string, bool) { + if limit <= 0 { + limit = 50 + } + ctx := context.Background() + if _, err := p.GetRevision(tenantID, revisionID); err != nil { + return nil, "", false + } + rows, err := p.pool.Query(ctx, ` + SELECT prefix::text, community_id::text, source FROM revision_materialized_prefix + WHERE revision_id=$1 ORDER BY id`, revisionID) + if err != nil { + return nil, "", false + } + defer rows.Close() + var all []store.PrefixRow + for rows.Next() { + var pr store.PrefixRow + var comm *string + if err := rows.Scan(&pr.Prefix, &comm, &pr.Source); err != nil { + continue + } + pr.CommunityID = comm + all = append(all, pr) + } + 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 (p *Postgres) CreateRollbackRevision(tenantID, sourceRevisionID string) (string, error) { + src, err := p.GetRevision(tenantID, sourceRevisionID) + if err != nil { + return "", err + } + ctx := context.Background() + newID := uuid.NewString() + parent := sourceRevisionID + meta, _ := json.Marshal(map[string]any{ + "preview_fragments": src.PreviewFragments, + "materialized_prefix_count": src.MaterializedPrefixCount, + }) + var modArg any + if strings.TrimSpace(src.ModuleID) != "" { + modArg = src.ModuleID + } + _, err = p.pool.Exec(ctx, ` + INSERT INTO config_revision (id, tenant_id, module_id, content_hash, parent_revision_id, meta_json) + VALUES ($1,$2,$3,$4,$5::uuid,$6::jsonb)`, + newID, tenantID, modArg, src.ContentHash+":rollback", parent, string(meta)) + if err != nil { + return "", err + } + // copy materialized prefixes + _, _ = p.pool.Exec(ctx, ` + INSERT INTO revision_materialized_prefix (revision_id, prefix, community_id, source, meta_json) + SELECT $1::uuid, prefix, community_id, source, meta_json FROM revision_materialized_prefix WHERE revision_id=$2::uuid`, + newID, sourceRevisionID) + return newID, nil +} + +func (p *Postgres) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) { + if _, err := p.GetRevision(tenantID, aID); err != nil { + return nil, err + } + if _, err := p.GetRevision(tenantID, bID); err != nil { + return nil, err + } + pa, _, _ := p.ListRevisionPrefixes(tenantID, aID, "", 100000) + pb, _, _ := p.ListRevisionPrefixes(tenantID, bID, "", 100000) + setA := make(map[string]struct{}) + setB := make(map[string]struct{}) + for _, x := range pa { + setA[x.Prefix] = struct{}{} + } + for _, x := range pb { + setB[x.Prefix] = struct{}{} + } + var added, removed []string + unchanged := 0 + for pfx := range setB { + if _, ok := setA[pfx]; !ok { + added = append(added, pfx) + } else { + unchanged++ + } + } + for pfx := range setA { + if _, ok := setB[pfx]; !ok { + removed = append(removed, pfx) + } + } + sort.Strings(added) + sort.Strings(removed) + return map[string]any{ + "revision_a": aID, + "revision_b": bID, + "prefixes": map[string]any{ + "added": added, "removed": removed, "unchanged_count": unchanged, + }, + }, nil +} + +func (p *Postgres) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, ` + UPDATE bgp_speaker SET last_applied_revision_id=$3::uuid, updated_at=now() + WHERE id=$1 AND tenant_id=$2`, speakerID, tenantID, revisionID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) PublishRevisionForSpeaker(speakerID, revisionID string) error { + ctx := context.Background() + tag, err := p.pool.Exec(ctx, ` + UPDATE bgp_speaker SET published_revision_id=$2::uuid, published_at=now(), updated_at=now() WHERE id=$1`, speakerID, revisionID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) LatestPublishedRevision(speakerID string) (string, time.Time, error) { + ctx := context.Background() + var rid string + var at time.Time + err := p.pool.QueryRow(ctx, ` + SELECT published_revision_id::text, published_at FROM bgp_speaker + WHERE id=$1 AND published_revision_id IS NOT NULL`, speakerID).Scan(&rid, &at) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return "", time.Time{}, store.ErrNotFound + } + return "", time.Time{}, err + } + return rid, at, nil +} + +func (p *Postgres) ListDohProfiles(tenantID string) ([]*store.DohProfile, error) { + ctx := context.Background() + rows, err := p.pool.Query(ctx, `SELECT id::text, name, url, timeout_ms, secret_ref FROM doh_profile WHERE tenant_id=$1`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*store.DohProfile + for rows.Next() { + var d store.DohProfile + d.TenantID = tenantID + var to *int32 + if err := rows.Scan(&d.ID, &d.Name, &d.URL, &to, &d.SecretRef); err != nil { + continue + } + if to != nil { + v := int(*to) + d.TimeoutMs = &v + } + out = append(out, &d) + } + return out, nil +} + +func (p *Postgres) GetDohProfile(tenantID, id string) (*store.DohProfile, error) { + ctx := context.Background() + var d store.DohProfile + d.TenantID = tenantID + var to *int32 + err := p.pool.QueryRow(ctx, `SELECT id::text, name, url, timeout_ms, secret_ref FROM doh_profile WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan( + &d.ID, &d.Name, &d.URL, &to, &d.SecretRef) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + if to != nil { + v := int(*to) + d.TimeoutMs = &v + } + return &d, nil +} + +func (p *Postgres) CreateDohProfile(tenantID string, in *store.DohProfile) (*store.DohProfile, error) { + if in == nil { + return nil, store.ErrInvalidInput + } + ctx := context.Background() + id := uuid.NewString() + _, err := p.pool.Exec(ctx, `INSERT INTO doh_profile (id, tenant_id, name, url, timeout_ms, secret_ref) VALUES ($1,$2,$3,$4,$5,$6)`, + id, tenantID, in.Name, in.URL, nullInt32Ptr(in.TimeoutMs), in.SecretRef) + if err != nil { + return nil, err + } + return p.GetDohProfile(tenantID, id) +} + +func (p *Postgres) UpdateDohProfile(tenantID, id string, patch *store.DohProfilePatch) (*store.DohProfile, error) { + cur, err := p.GetDohProfile(tenantID, id) + if err != nil { + return nil, err + } + if patch.Name != nil { + cur.Name = *patch.Name + } + if patch.URL != nil { + cur.URL = *patch.URL + } + if patch.TimeoutMs != nil { + cur.TimeoutMs = patch.TimeoutMs + } + if patch.SecretRef != nil { + cur.SecretRef = patch.SecretRef + } + ctx := context.Background() + _, err = p.pool.Exec(ctx, `UPDATE doh_profile SET name=$3, url=$4, timeout_ms=$5, secret_ref=$6, updated_at=now() WHERE id=$1 AND tenant_id=$2`, + id, tenantID, cur.Name, cur.URL, nullInt32Ptr(cur.TimeoutMs), cur.SecretRef) + if err != nil { + return nil, err + } + return p.GetDohProfile(tenantID, id) +} + +func (p *Postgres) DeleteDohProfile(tenantID, id string) error { + ctx := context.Background() + var n int + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE doh_profile_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n) + if n > 0 { + return store.ErrInvalidInput + } + tag, err := p.pool.Exec(ctx, `DELETE FROM doh_profile WHERE id=$1 AND tenant_id=$2`, id, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +func (p *Postgres) ListCommunities(tenantID string) ([]*store.Community, error) { + ctx := context.Background() + rows, err := p.pool.Query(ctx, `SELECT id::text, name, kind, value_json::text FROM bgp_community WHERE tenant_id=$1`, tenantID) + if err != nil { + return nil, err + } + defer rows.Close() + var out []*store.Community + for rows.Next() { + var c store.Community + c.TenantID = tenantID + if err := rows.Scan(&c.ID, &c.Name, &c.Kind, &c.ValueJSON); err != nil { + continue + } + out = append(out, &c) + } + return out, nil +} + +func (p *Postgres) GetCommunity(tenantID, id string) (*store.Community, error) { + ctx := context.Background() + var c store.Community + c.TenantID = tenantID + err := p.pool.QueryRow(ctx, `SELECT id::text, name, kind, value_json::text FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID).Scan( + &c.ID, &c.Name, &c.Kind, &c.ValueJSON) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, store.ErrNotFound + } + return nil, err + } + return &c, nil +} + +func (p *Postgres) CreateCommunity(tenantID string, in *store.Community) (*store.Community, error) { + if in == nil { + return nil, store.ErrInvalidInput + } + ctx := context.Background() + id := uuid.NewString() + vj := in.ValueJSON + if strings.TrimSpace(vj) == "" { + vj = "{}" + } + _, err := p.pool.Exec(ctx, `INSERT INTO bgp_community (id, tenant_id, name, kind, value_json) VALUES ($1,$2,$3,$4,$5::jsonb)`, + id, tenantID, in.Name, in.Kind, vj) + if err != nil { + return nil, err + } + return p.GetCommunity(tenantID, id) +} + +func (p *Postgres) UpdateCommunity(tenantID, id string, patch *store.CommunityPatch) (*store.Community, error) { + cur, err := p.GetCommunity(tenantID, id) + if err != nil { + return nil, err + } + if patch.Name != nil { + cur.Name = *patch.Name + } + if patch.Kind != nil { + cur.Kind = *patch.Kind + } + if patch.ValueJSON != nil { + cur.ValueJSON = *patch.ValueJSON + } + ctx := context.Background() + _, err = p.pool.Exec(ctx, `UPDATE bgp_community SET name=$3, kind=$4, value_json=$5::jsonb, updated_at=now() WHERE id=$1 AND tenant_id=$2`, + id, tenantID, cur.Name, cur.Kind, cur.ValueJSON) + if err != nil { + return nil, err + } + return p.GetCommunity(tenantID, id) +} + +func (p *Postgres) DeleteCommunity(tenantID, id string) error { + ctx := context.Background() + var n int + _ = p.pool.QueryRow(ctx, `SELECT COUNT(*) FROM module WHERE default_community_id=$1::uuid AND deleted_at IS NULL`, id).Scan(&n) + if n > 0 { + return store.ErrInvalidInput + } + tag, err := p.pool.Exec(ctx, `DELETE FROM bgp_community WHERE id=$1 AND tenant_id=$2`, id, tenantID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return store.ErrNotFound + } + return nil +} + +// CDN / AS / domain / IP stubs: implement in postgres_sub.go to keep file size manageable. + +func strPtrUUID(s *string) *string { + if s == nil || strings.TrimSpace(*s) == "" { + return nil + } + v := strings.TrimSpace(*s) + return &v +} + +func strOrNil(s *string) *string { + if s == nil || *s == "" { + return nil + } + return s +} + +func nullStr(s string) *string { + if strings.TrimSpace(s) == "" { + return nil + } + v := strings.TrimSpace(s) + return &v +} + +func nullInt32(i int) *int32 { + if i == 0 { + return nil + } + v := int32(i) + return &v +} + +func nullIntOrZero(i int) any { + if i == 0 { + return nil + } + return i +} + +func nullInt32Ptr(i *int) *int32 { + if i == nil { + return nil + } + v := int32(*i) + return &v +} + +func nullJSON(s string) *string { + if strings.TrimSpace(s) == "" { + v := "{}" + return &v + } + return &s +} diff --git a/internal/repository/postgres_entities.go b/internal/repository/postgres_entities.go new file mode 100644 index 0000000..3c2c726 --- /dev/null +++ b/internal/repository/postgres_entities.go @@ -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) diff --git a/internal/repository/postgres_seed.go b/internal/repository/postgres_seed.go new file mode 100644 index 0000000..d9af715 --- /dev/null +++ b/internal/repository/postgres_seed.go @@ -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 +} diff --git a/internal/scheduler/run.go b/internal/scheduler/run.go index 7215518..281ccbc 100644 --- a/internal/scheduler/run.go +++ b/internal/scheduler/run.go @@ -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)") diff --git a/internal/store/backend.go b/internal/store/backend.go new file mode 100644 index 0000000..1563505 --- /dev/null +++ b/internal/store/backend.go @@ -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 +} diff --git a/internal/store/memory.go b/internal/store/memory.go index a8e7a5b..1d1f66e 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -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 } diff --git a/internal/store/memory_crud.go b/internal/store/memory_crud.go new file mode 100644 index 0000000..2ad5ee9 --- /dev/null +++ b/internal/store/memory_crud.go @@ -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 +} diff --git a/migrations/postgres/000002_speaker_publish.down.sql b/migrations/postgres/000002_speaker_publish.down.sql new file mode 100644 index 0000000..7539693 --- /dev/null +++ b/migrations/postgres/000002_speaker_publish.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE bgp_speaker + DROP COLUMN IF EXISTS published_at, + DROP COLUMN IF EXISTS published_revision_id; diff --git a/migrations/postgres/000002_speaker_publish.up.sql b/migrations/postgres/000002_speaker_publish.up.sql new file mode 100644 index 0000000..db04812 --- /dev/null +++ b/migrations/postgres/000002_speaker_publish.up.sql @@ -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; diff --git a/migrations/sqlite/000002_speaker_publish.down.sql b/migrations/sqlite/000002_speaker_publish.down.sql new file mode 100644 index 0000000..c51d11f --- /dev/null +++ b/migrations/sqlite/000002_speaker_publish.down.sql @@ -0,0 +1,2 @@ +-- SQLite cannot DROP COLUMN easily in older versions; no-op for dev rollback. +SELECT 1; diff --git a/migrations/sqlite/000002_speaker_publish.up.sql b/migrations/sqlite/000002_speaker_publish.up.sql new file mode 100644 index 0000000..5e015a7 --- /dev/null +++ b/migrations/sqlite/000002_speaker_publish.up.sql @@ -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; diff --git a/scripts/expand-go-sum.ps1 b/scripts/expand-go-sum.ps1 new file mode 100644 index 0000000..6d1fb9f --- /dev/null +++ b/scripts/expand-go-sum.ps1 @@ -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" diff --git a/web/package-lock.json b/web/package-lock.json index cdc83c7..9dc33c8 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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/*", diff --git a/web/package.json b/web/package.json index 346fdc0..5be9334 100644 --- a/web/package.json +++ b/web/package.json @@ -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",