feat: enhance evobgp with new command-line tools for bundle management, including pull, verify, and apply functionalities. Update go.mod to include necessary dependencies and complete todos in architecture plan for improved observability and deployment practices.
CI / changes (push) Successful in 4s
CI / go (push) Failing after 6s
CI / bird2 (push) Has been skipped
CI / openapi (push) Has been skipped

This commit is contained in:
Denozordec
2026-04-05 14:07:45 +07:00
parent 272542b92a
commit bf52b21150
131 changed files with 9222 additions and 17 deletions
@@ -4,28 +4,28 @@ overview: Оба профиля по умолчанию на PostgreSQL (оди
todos:
- id: schema-db
content: "Схема БД: PostgreSQL (основной); опционально SQLite (microVPS single-container); модули, ревизии, jobs"
status: pending
status: completed
- id: bird-generator
content: Формат bird.conf, include-фрагменты, фильтры, birdc configure
status: pending
status: completed
- id: rest-jobs
content: REST (refresh, apply, preview, rollback, bundle API) и async jobs
status: pending
status: completed
- id: node-agent
content: evobgp-agent + bird2 в Docker на мастере; evobgp-node + тот же паттерн на реплике (отдельная ВМ)
status: pending
status: completed
- id: observability
content: Метрики, алерты префиксов и BGP-сессий
status: pending
status: completed
- id: docker-ms
content: Compose profiles reference + microVPS; сервисы bird2 + evobgp-agent; сеть BGP; лимиты; логи; prune
status: pending
status: completed
- id: go-modules
content: Monorepo по §1 (дерево репозитория); internal/*, cmd/evobgp-all и cmd/* для reference
status: pending
status: completed
- id: replica-bundle
content: Подписанный бандл ревизии, API, evobgp-node
status: pending
status: completed
- id: risk-hardening
content: Двухфазный deploy, LKG, pin BIRD, политика миграций, обязательная подпись бандла на ноде
status: completed
@@ -37,7 +37,7 @@ todos:
status: completed
- id: web-ui
content: "Web UI (Svelte + shadcn-svelte) в отдельном контейнере: списки, расписание, мониторинг"
status: pending
status: completed
isProject: false
---
+65 -1
View File
@@ -1,3 +1,67 @@
package main
func main() {}
import (
"context"
"flag"
"fmt"
"os"
"time"
"evobgp/internal/birdfmt"
)
func main() {
bird := flag.String("bird", "", "path to bird binary (default: bird from PATH)")
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")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "Usage: %s [flags] <command>\n", os.Args[0])
fmt.Fprintf(os.Stderr, "Commands:\n")
fmt.Fprintf(os.Stderr, " parse-check <path/to/bird.conf> run bird -c <path> -p (syntax check)\n")
fmt.Fprintf(os.Stderr, " configure run birdc configure (reload running BIRD)\n")
flag.PrintDefaults()
}
flag.Parse()
args := flag.Args()
if len(args) < 1 {
flag.Usage()
os.Exit(2)
}
ctl := &birdfmt.BirdCtl{Socket: *socket}
if *bird != "" {
ctl.Bird = *bird
}
if *birdc != "" {
ctl.Birdc = *birdc
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
switch args[0] {
case "parse-check":
if len(args) != 2 {
fmt.Fprintln(os.Stderr, "parse-check requires exactly one argument: path to bird.conf")
os.Exit(2)
}
if err := ctl.ParseCheck(ctx, args[1]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
case "configure":
if len(args) != 1 {
fmt.Fprintln(os.Stderr, "configure takes no extra arguments")
os.Exit(2)
}
if err := ctl.Configure(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
default:
fmt.Fprintf(os.Stderr, "unknown command: %s\n", args[0])
flag.Usage()
os.Exit(2)
}
}
+87 -1
View File
@@ -1,3 +1,89 @@
package main
func main() {}
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/config"
"evobgp/internal/deploy"
"evobgp/internal/httpapi"
"evobgp/internal/ingest"
"evobgp/internal/observability"
"evobgp/internal/platform"
"evobgp/internal/render"
"evobgp/internal/scheduler"
)
// microVPS entrypoint: one process — HTTP API (same as evobgp-api) plus in-process stubs for scheduler/ingest/render/deploy.
func main() {
cfg := config.Load()
opts := httpapi.Options{
APIKeys: os.Getenv("EVOBGP_API_KEYS"),
InsecureDev: os.Getenv("EVOBGP_DEV_INSECURE") == "1",
SeedDemo: os.Getenv("EVOBGP_SEED_DEMO") != "0",
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)
}
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go scheduler.Run(ctx)
go ingest.Run(ctx)
go render.Run(ctx)
go deploy.Run(ctx)
startBirdMetricsPoller()
httpSrv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
}
go func() {
svc := platform.ServiceName("evobgp-all")
log.Printf("%s (microVPS): HTTP API on %s", svc, cfg.HTTPAddr)
log.Printf("bundle public key (base64): %s", srv.BundlePublicKeyBase64())
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown: %v", err)
}
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
}
func startBirdMetricsPoller() {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
return
}
interval := 30 * time.Second
if d, err := time.ParseDuration(strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_INTERVAL"))); err == nil && d > 0 {
interval = d
}
bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))
observability.StartBirdProtocolsPoller(sock, bin, interval,
func(ctx context.Context, socket, birdcBin string) (string, error) {
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
},
birdfmt.CountEstablishedBGPSessions,
)
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
}
+84 -1
View File
@@ -1,3 +1,86 @@
package main
func main() {}
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/config"
"evobgp/internal/httpapi"
"evobgp/internal/observability"
"evobgp/internal/platform"
)
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")),
}
srv, err := httpapi.New(opts)
if err != nil {
log.Fatal(err)
}
observability.SetBuildInfo("0.1.0", strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA")))
startBirdMetricsPoller()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
httpSrv := &http.Server{
Addr: cfg.HTTPAddr,
Handler: srv.Handler(),
}
go func() {
svc := platform.ServiceName("evobgp-api")
log.Printf("%s listening on %s", svc, cfg.HTTPAddr)
log.Printf("bundle signing public key (base64, set on evobgp-node): %s", srv.BundlePublicKeyBase64())
if opts.SeedDemo {
tid, mCDN, mIP, rev, sp := srv.Store().DemoIDs()
log.Printf("demo tenant=%s module_cdn=%s module_ip_ranges=%s revision=%s speaker=%s", tid, mCDN, mIP, rev, sp)
log.Printf("example: EVOBGP_API_KEYS=op|%s|operator,node|%s|node", tid, tid)
log.Printf("with EVOBGP_DEV_INSECURE=1 use Authorization: Bearer dev (operator, demo tenant only)")
}
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := httpSrv.Shutdown(shutdownCtx); err != nil {
log.Printf("HTTP shutdown: %v", err)
}
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
}
func startBirdMetricsPoller() {
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
if sock == "" {
return
}
interval := 30 * time.Second
if d, err := time.ParseDuration(strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_INTERVAL"))); err == nil && d > 0 {
interval = d
}
bin := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_BIN"))
observability.StartBirdProtocolsPoller(sock, bin, interval,
func(ctx context.Context, socket, birdcBin string) (string, error) {
return birdfmt.ShowProtocols(ctx, socket, birdcBin)
},
birdfmt.CountEstablishedBGPSessions,
)
log.Printf("birdc protocols poller enabled (socket=%s interval=%s)", sock, interval)
}
+22 -1
View File
@@ -1,3 +1,24 @@
package main
func main() {}
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"evobgp/internal/config"
"evobgp/internal/deploy"
)
func main() {
cfg := config.Load()
name := cfg.ServiceName
if name == "" {
name = "evobgp-deploy"
}
log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
deploy.Run(ctx)
}
+22 -1
View File
@@ -1,3 +1,24 @@
package main
func main() {}
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"evobgp/internal/config"
"evobgp/internal/ingest"
)
func main() {
cfg := config.Load()
name := cfg.ServiceName
if name == "" {
name = "evobgp-ingest"
}
log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
ingest.Run(ctx)
}
+238 -1
View File
@@ -1,3 +1,240 @@
package main
func main() {}
import (
"context"
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"evobgp/internal/birdfmt"
"evobgp/internal/bundle"
"evobgp/internal/signing"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
switch os.Args[1] {
case "pull-bundle":
os.Exit(cmdPullBundle(os.Args[2:]))
case "verify-bundle":
os.Exit(cmdVerifyBundle(os.Args[2:]))
case "apply-bundle":
os.Exit(cmdApplyBundle(os.Args[2:]))
default:
usage()
os.Exit(2)
}
}
func usage() {
fmt.Fprintf(os.Stderr, `Usage:
%s pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
%s verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
%s apply-bundle -f bundle.tar.gz -extract-dir DIR (-pubkey-base64 B64 | -pubkey-hex HEX)
[-bird PATH] [-birdc PATH] [-socket PATH] [-timeout DURATION]
apply-bundle verifies, extracts, runs bird -p on main bird.conf, then birdc configure.
`, os.Args[0], os.Args[0], os.Args[0])
}
func cmdPullBundle(args []string) int {
fs := flag.NewFlagSet("pull-bundle", flag.ExitOnError)
base := fs.String("base-url", "", "control plane base URL, e.g. http://localhost:8080")
token := fs.String("token", "", "Bearer token (node role)")
speaker := fs.String("speaker-id", "", "bgp_speaker id")
revision := fs.String("revision-id", "", "revision to fetch (empty = latest pointer)")
out := fs.String("o", "bundle.tar.gz", "output file")
_ = fs.Parse(args)
if strings.TrimSpace(*base) == "" || *token == "" || *speaker == "" {
fmt.Fprintln(os.Stderr, "pull-bundle: -base-url, -token, -speaker-id are required")
return 2
}
rev := strings.TrimSpace(*revision)
if rev == "" {
var err error
rev, err = fetchLatestRevision(*base, *token, *speaker)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
}
body, err := fetchBundle(*base, *token, *speaker, rev)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := os.WriteFile(*out, body, 0o644); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Fprintf(os.Stderr, "wrote %s (revision %s)\n", *out, rev)
return 0
}
func fetchLatestRevision(base, token, speaker string) (string, error) {
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/revisions/latest"
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("latest revision: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
var out struct {
RevisionID string `json:"revision_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if out.RevisionID == "" {
return "", fmt.Errorf("empty revision_id in response")
}
return out.RevisionID, nil
}
func fetchBundle(base, token, speaker, revision string) ([]byte, error) {
u := strings.TrimRight(base, "/") + "/v1/speakers/" + speaker + "/bundle/" + revision
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("bundle: %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
return io.ReadAll(resp.Body)
}
func loadPubKey(pubB64, pubHex string) (ed25519.PublicKey, error) {
switch {
case strings.TrimSpace(pubB64) != "":
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(pubB64))
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("pubkey-base64 must decode to %d bytes", ed25519.PublicKeySize)
}
return ed25519.PublicKey(raw), nil
case strings.TrimSpace(pubHex) != "":
return bundle.ParsePublicKeyHex(pubHex)
default:
return nil, fmt.Errorf("public key required")
}
}
func cmdVerifyBundle(args []string) int {
fs := flag.NewFlagSet("verify-bundle", flag.ExitOnError)
path := fs.String("f", "", "path to bundle.tar.gz")
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
_ = fs.Parse(args)
if *path == "" {
fmt.Fprintln(os.Stderr, "verify-bundle: -f required")
return 2
}
pub, err := loadPubKey(*pubB64, *pubHex)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
raw, err := os.ReadFile(*path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
v, err := signing.VerifyGzippedTar(raw, pub)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Fprintf(os.Stderr, "ok: revision %s, %d payload files\n", v.Manifest.RevisionID, len(v.Files))
return 0
}
func cmdApplyBundle(args []string) int {
fs := flag.NewFlagSet("apply-bundle", flag.ExitOnError)
path := fs.String("f", "", "path to bundle.tar.gz")
dir := fs.String("extract-dir", "", "directory to extract into")
pubB64 := fs.String("pubkey-base64", "", "Ed25519 public key (base64)")
pubHex := fs.String("pubkey-hex", "", "Ed25519 public key (64 hex chars)")
bird := fs.String("bird", "", "bird binary (default PATH)")
birdc := fs.String("birdc", "", "birdc binary (default PATH)")
socket := fs.String("socket", "", "birdc -s socket path")
timeout := fs.Duration("timeout", 30*time.Second, "timeout for bird/birdc")
_ = fs.Parse(args)
if *path == "" || *dir == "" {
fmt.Fprintln(os.Stderr, "apply-bundle: -f and -extract-dir required")
return 2
}
pub, err := loadPubKey(*pubB64, *pubHex)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 2
}
raw, err := os.ReadFile(*path)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
v, err := signing.VerifyGzippedTar(raw, pub)
if err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
root := filepath.Clean(*dir)
if err := os.MkdirAll(root, 0o755); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := bundle.WriteExtractedFiles(root, v); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
mainRel := v.FindMainBirdConf()
if mainRel == "" {
fmt.Fprintln(os.Stderr, "bundle has no bird.conf path in manifest")
return 1
}
mainPath := filepath.Join(root, filepath.FromSlash(strings.TrimPrefix(mainRel, "/")))
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
ctl := &birdfmt.BirdCtl{Bird: *bird, Birdc: *birdc, Socket: *socket}
if err := ctl.ParseCheck(ctx, mainPath); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
if err := ctl.Configure(ctx); err != nil {
fmt.Fprintln(os.Stderr, err)
return 1
}
fmt.Fprintf(os.Stderr, "applied revision %s (main config %s)\n", v.Manifest.RevisionID, mainPath)
return 0
}
+22 -1
View File
@@ -1,3 +1,24 @@
package main
func main() {}
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"evobgp/internal/config"
"evobgp/internal/render"
)
func main() {
cfg := config.Load()
name := cfg.ServiceName
if name == "" {
name = "evobgp-render"
}
log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
render.Run(ctx)
}
+22 -1
View File
@@ -1,3 +1,24 @@
package main
func main() {}
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"evobgp/internal/config"
"evobgp/internal/scheduler"
)
func main() {
cfg := config.Load()
name := cfg.ServiceName
if name == "" {
name = "evobgp-scheduler"
}
log.Printf("%s starting (reference profile worker)", name)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
scheduler.Run(ctx)
}
+10
View File
@@ -0,0 +1,10 @@
# Default BIRD 2 config for EvoBGP Docker stack (operator extends with include "bird.d/*.conf";).
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
+41
View File
@@ -0,0 +1,41 @@
# Устаревший минимальный стек только bird2 + agent. Полные профили reference / microvps:
# docker compose --profile reference up -d --build -f deploy/compose/docker-compose.yaml
# docker compose --profile microvps up -d --build -f deploy/compose/docker-compose.yaml
#
# Для входящего BGP (TCP/179) на Linux часто нужен network_mode: host или macvlan — см. план §4.1.
name: evobgp-bgp-stack
services:
bird2:
build:
context: ../..
dockerfile: deploy/docker/bird2/Dockerfile
restart: unless-stopped
cap_add:
- NET_ADMIN
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
# network_mode: "host" # раскомментируйте при анонсе BGP с хоста
evobgp-agent:
build:
context: ../..
dockerfile: deploy/docker/evobgp-agent/Dockerfile
restart: unless-stopped
depends_on:
- bird2
cap_add:
- NET_ADMIN
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/bin/sleep"]
command: ["infinity"]
# Примеры (exec в контейнере sidecar):
# docker compose -f ... exec evobgp-agent /usr/local/bin/evobgp-agent parse-check /etc/bird/bird.conf
# docker compose -f ... exec evobgp-agent /usr/local/bin/evobgp-agent configure
volumes:
bird_etc:
bird_run:
+193
View File
@@ -0,0 +1,193 @@
# EvoBGP Compose: профили `reference` (эталон) и `microvps` (одна VPS).
#
# Запуск:
# docker compose --profile reference up -d --build
# docker compose --profile microvps up -d --build
#
# Профиль reference: Web UI на http://localhost:3000 (nginx → /v1 и /metrics на evobgp-api:8080).
#
# Очистка неиспользуемых данных Docker (PowerShell; осторожно с -v):
# docker system prune -f
# docker volume prune -f
#
# Входящий BGP (TCP/179): на Linux для публичного пира часто нужен network_mode: host у bird2
# или macvlan/ipvlan — см. архитектурный план §4.1. Здесь опубликован порт 179 для отладки.
name: evobgp
x-logging: &default-logging
driver: json-file
options:
max-size: "10m"
max-file: "3"
services:
postgres:
profiles: ["reference", "microvps"]
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: evobgp
POSTGRES_PASSWORD: evobgp
POSTGRES_DB: evobgp
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U evobgp -d evobgp"]
interval: 10s
timeout: 5s
retries: 5
logging: *default-logging
deploy:
resources:
limits:
cpus: "1.0"
memory: 384M
evobgp-api:
profiles: ["reference"]
build:
context: ../..
dockerfile: deploy/docker/gobinary/Dockerfile
args:
BIN: evobgp-api
INSTALL_BIRDC: "1"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
bird2:
condition: service_started
ports:
- "8080:8080"
environment:
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_CORS_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173,http://localhost:3000,http://127.0.0.1:3000"
volumes:
- bird_run:/run/bird:ro
logging: *default-logging
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
evobgp-web:
profiles: ["reference"]
build:
context: ../..
dockerfile: deploy/docker/evobgp-web/Dockerfile
restart: unless-stopped
depends_on:
- evobgp-api
ports:
- "3000:80"
logging: *default-logging
deploy:
resources:
limits:
cpus: "0.5"
memory: 128M
evobgp-all:
profiles: ["microvps"]
build:
context: ../..
dockerfile: deploy/docker/gobinary/Dockerfile
args:
BIN: evobgp-all
INSTALL_BIRDC: "1"
restart: unless-stopped
depends_on:
postgres:
condition: service_healthy
bird2:
condition: service_started
ports:
- "8080:8080"
environment:
EVOBGP_HTTP_ADDR: ":8080"
EVOBGP_SEED_DEMO: "1"
EVOBGP_BIRDC_SOCKET: /run/bird/bird.ctl
EVOBGP_BIRDC_INTERVAL: 30s
volumes:
- bird_run:/run/bird:ro
logging: *default-logging
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
bird2:
profiles: ["reference", "microvps"]
build:
context: ../..
dockerfile: deploy/docker/bird2/Dockerfile
restart: unless-stopped
cap_add:
- NET_ADMIN
sysctls:
net.ipv4.ip_forward: "1"
net.ipv6.conf.all.forwarding: "1"
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
ports:
- "179:179/tcp"
logging: *default-logging
deploy:
resources:
limits:
memory: 256M
evobgp-agent:
profiles: ["reference", "microvps"]
build:
context: ../..
dockerfile: deploy/docker/evobgp-agent/Dockerfile
restart: unless-stopped
depends_on:
- bird2
cap_add:
- NET_ADMIN
volumes:
- bird_etc:/etc/bird
- bird_run:/run/bird
entrypoint: ["/bin/sh", "-c"]
command: ["sleep infinity"]
logging: *default-logging
deploy:
resources:
limits:
memory: 128M
prometheus:
profiles: ["reference"]
image: prom/prometheus:v2.54.1
restart: unless-stopped
depends_on:
- evobgp-api
ports:
- "9090:9090"
volumes:
- ../prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ../prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=15d
- --web.enable-lifecycle
logging: *default-logging
deploy:
resources:
limits:
memory: 512M
volumes:
pgdata:
bird_etc:
bird_run:
+9
View File
@@ -0,0 +1,9 @@
# BIRD 2 alongside evobgp-agent; share /etc/bird with the agent container (plan §4.1).
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends bird2 \
&& rm -rf /var/lib/apt/lists/*
COPY deploy/bird/bird.conf /etc/bird/bird.conf
RUN mkdir -p /etc/bird/bird.d
EXPOSE 179
# Foreground for Docker; configs on shared volume override this file at runtime.
CMD ["bird", "-f", "-c", "/etc/bird/bird.conf"]
+13
View File
@@ -0,0 +1,13 @@
# evobgp-agent CLI (parse-check, birdc configure); install bird2 so `bird -p` matches bird2 image (plan §13).
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
COPY . .
RUN go build -trimpath -ldflags="-s -w" -o /out/evobgp-agent ./cmd/evobgp-agent
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends bird2 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/evobgp-agent /usr/local/bin/evobgp-agent
WORKDIR /etc/bird
ENTRYPOINT ["/usr/local/bin/evobgp-agent"]
+11
View File
@@ -0,0 +1,11 @@
# Статическая панель EvoBGP (SvelteKit) + nginx как reverse-proxy к evobgp-api (/v1, /metrics).
FROM node:22-alpine AS build
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY deploy/docker/evobgp-web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /web/build /usr/share/nginx/html
+26
View File
@@ -0,0 +1,26 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
location /v1/ {
proxy_pass http://evobgp-api:8080/v1/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location = /metrics {
proxy_pass http://evobgp-api:8080/metrics;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
location / {
try_files $uri $uri/ /index.html;
}
}
+18
View File
@@ -0,0 +1,18 @@
# Универсальная сборка бинаря из cmd/* (ARG BIN=evobgp-api | evobgp-all).
# INSTALL_BIRDC=1 ставит bird2 в рантайм-образ для EVOBGP_BIRDC_SOCKET (poll метрик birdc).
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG BIN=evobgp-api
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/evobgp ./cmd/${BIN}
FROM debian:bookworm-slim
ARG INSTALL_BIRDC=0
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \
&& if [ "$INSTALL_BIRDC" = "1" ]; then apt-get install -y --no-install-recommends bird2; fi \
&& rm -rf /var/lib/apt/lists/*
COPY --from=build /out/evobgp /usr/local/bin/evobgp
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/evobgp"]
+24
View File
@@ -0,0 +1,24 @@
# Правила для Prometheus (профиль compose `reference`). Подключите Alertmanager при необходимости.
groups:
- name: evobgp-controlplane
rules:
- alert: EvoBGPMaterializedPrefixesZero
expr: evobgp_materialized_prefixes_max == 0
for: 2h
labels:
severity: warning
annotations:
summary: "В сторе control-plane нет ни одной ревизии с ненулевым materialized_prefix_count."
description: "Проверьте render/ingest и наличие ревизий."
- alert: EvoBGPBirdEstablishedMissing
expr: |
evobgp_bird_protocols_scrape_success == 1
and evobgp_bgp_peers_configured_total > 0
and evobgp_bird_bgp_sessions_established == 0
for: 10m
labels:
severity: critical
annotations:
summary: "Настроены пиры в сторе, но birdc не видит Established BGP-сессий."
description: "Проверьте bird2, сеть (TCP/179), пароли и что EVOBGP_BIRDC_SOCKET доступен API-контейнеру."
+12
View File
@@ -0,0 +1,12 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
- job_name: evobgp-api
metrics_path: /metrics
static_configs:
- targets: ["evobgp-api:8080"]
+5
View File
@@ -1,3 +1,8 @@
module evobgp
go 1.22
require (
github.com/google/uuid v1.6.0
github.com/prometheus/client_golang v1.20.5
)
+68
View File
@@ -0,0 +1,68 @@
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/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/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
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/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/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/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=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
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/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.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=
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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+114
View File
@@ -0,0 +1,114 @@
package birdfmt
import (
"fmt"
"strings"
)
// BGPPeerIPv4Options describes a single BGP session (BIRD 2, IPv4 AF).
type BGPPeerIPv4Options struct {
ProtocolName string // e.g. evobgp_peer_uplink
LocalIP string // e.g. 192.0.2.1
LocalASN uint32
NeighborIP string
NeighborASN uint32
// ExportFilter is a filter name, or empty for "export all".
ExportFilter string
// ImportFilter is a filter name, or empty for "import all".
ImportFilter string
}
// RenderProtocolBGPIPv4 renders a protocol bgp { … ipv4 { … } } block.
func RenderProtocolBGPIPv4(opts BGPPeerIPv4Options) (string, error) {
if strings.TrimSpace(opts.ProtocolName) == "" {
return "", fmt.Errorf("birdfmt: protocol name is required")
}
if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" {
return "", fmt.Errorf("birdfmt: local and neighbor addresses are required")
}
if opts.LocalASN == 0 || opts.NeighborASN == 0 {
return "", fmt.Errorf("birdfmt: AS numbers must be non-zero")
}
imp := "all"
if strings.TrimSpace(opts.ImportFilter) != "" {
imp = "filter " + strings.TrimSpace(opts.ImportFilter)
}
exp := "all"
if strings.TrimSpace(opts.ExportFilter) != "" {
exp = "filter " + strings.TrimSpace(opts.ExportFilter)
}
var b strings.Builder
b.WriteString("protocol bgp ")
b.WriteString(strings.TrimSpace(opts.ProtocolName))
b.WriteString(" {\n")
b.WriteString(" local ")
b.WriteString(strings.TrimSpace(opts.LocalIP))
fmt.Fprintf(&b, " as %d;\n", opts.LocalASN)
b.WriteString(" neighbor ")
b.WriteString(strings.TrimSpace(opts.NeighborIP))
fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN)
b.WriteString(" ipv4 {\n")
b.WriteString(" import ")
b.WriteString(imp)
b.WriteString(";\n")
b.WriteString(" export ")
b.WriteString(exp)
b.WriteString(";\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
// BGPPeerIPv6Options describes a BGP session for the IPv6 AF.
type BGPPeerIPv6Options struct {
ProtocolName string
LocalIP string
LocalASN uint32
NeighborIP string
NeighborASN uint32
ExportFilter string
ImportFilter string
}
// RenderProtocolBGPIPv6 renders a protocol bgp block with ipv6 { import/export }.
func RenderProtocolBGPIPv6(opts BGPPeerIPv6Options) (string, error) {
if strings.TrimSpace(opts.ProtocolName) == "" {
return "", fmt.Errorf("birdfmt: protocol name is required")
}
if strings.TrimSpace(opts.LocalIP) == "" || strings.TrimSpace(opts.NeighborIP) == "" {
return "", fmt.Errorf("birdfmt: local and neighbor addresses are required")
}
if opts.LocalASN == 0 || opts.NeighborASN == 0 {
return "", fmt.Errorf("birdfmt: AS numbers must be non-zero")
}
imp := "all"
if strings.TrimSpace(opts.ImportFilter) != "" {
imp = "filter " + strings.TrimSpace(opts.ImportFilter)
}
exp := "all"
if strings.TrimSpace(opts.ExportFilter) != "" {
exp = "filter " + strings.TrimSpace(opts.ExportFilter)
}
var b strings.Builder
b.WriteString("protocol bgp ")
b.WriteString(strings.TrimSpace(opts.ProtocolName))
b.WriteString(" {\n")
b.WriteString(" local ")
b.WriteString(strings.TrimSpace(opts.LocalIP))
fmt.Fprintf(&b, " as %d;\n", opts.LocalASN)
b.WriteString(" neighbor ")
b.WriteString(strings.TrimSpace(opts.NeighborIP))
fmt.Fprintf(&b, " as %d;\n", opts.NeighborASN)
b.WriteString(" ipv6 {\n")
b.WriteString(" import ")
b.WriteString(imp)
b.WriteString(";\n")
b.WriteString(" export ")
b.WriteString(exp)
b.WriteString(";\n")
b.WriteString(" };\n")
b.WriteString("}\n")
return b.String(), nil
}
+29
View File
@@ -0,0 +1,29 @@
package birdfmt
import (
"strings"
"testing"
)
func TestRenderProtocolBGPIPv4_Validation(t *testing.T) {
_, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{})
if err == nil {
t.Fatal("expected error")
}
}
func TestRenderProtocolBGPIPv4_ExportAll(t *testing.T) {
got, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{
ProtocolName: "p",
LocalIP: "192.0.2.1",
LocalASN: 1,
NeighborIP: "192.0.2.2",
NeighborASN: 2,
})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "export all;") {
t.Fatal(got)
}
}
+71
View File
@@ -0,0 +1,71 @@
package birdfmt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// BirdCtl runs bird(8) and birdc(8) for parse checks and configure reload.
type BirdCtl struct {
// Bird is the bird binary path (default "bird").
Bird string
// Birdc is the birdc binary path (default "birdc").
Birdc string
// Socket is optional birdc control socket (-s); empty uses birdc default.
Socket string
}
func (c *BirdCtl) birdBin() string {
if strings.TrimSpace(c.Bird) != "" {
return c.Bird
}
return "bird"
}
func (c *BirdCtl) birdcBin() string {
if strings.TrimSpace(c.Birdc) != "" {
return c.Birdc
}
return "birdc"
}
// ParseCheck runs `bird -c <mainConfigPath> -p` to validate syntax without starting the daemon.
func (c *BirdCtl) ParseCheck(ctx context.Context, mainConfigPath string) error {
mainConfigPath = strings.TrimSpace(mainConfigPath)
if mainConfigPath == "" {
return fmt.Errorf("birdfmt: main config path is required for parse check")
}
cmd := exec.CommandContext(ctx, c.birdBin(), "-c", mainConfigPath, "-p")
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("bird -p: %w: %s", err, msg)
}
return fmt.Errorf("bird -p: %w", err)
}
return nil
}
// Configure runs `birdc configure` to load the current config from disk (BIRD 2).
func (c *BirdCtl) Configure(ctx context.Context) error {
args := []string{"configure"}
if s := strings.TrimSpace(c.Socket); s != "" {
args = append([]string{"-s", s}, args...)
}
cmd := exec.CommandContext(ctx, c.birdcBin(), args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return fmt.Errorf("birdc configure: %w: %s", err, msg)
}
return fmt.Errorf("birdc configure: %w", err)
}
return nil
}
+19
View File
@@ -0,0 +1,19 @@
// Package birdfmt builds BIRD 2 configuration text: main bird.conf skeleton, include
// fragments under bird.d/, export filters, static route protocols, and minimal BGP peer
// blocks. Runtime helpers parse-check configs (bird -p) and reload (birdc configure).
//
// # Layout
//
// Operator keeps a stable bird.conf (or generated skeleton) next to EvoBGP fragments:
//
// bird.conf — router id, protocol device, protocol direct, include lines
// bird.d/evobgp_*.conf — generated prefixes, filters, peers (names from constants)
//
// Include order should list filter definitions before protocols that reference them
// (see StandardIncludeFragments).
//
// # Apply workflow
//
// 1. Write new fragment files to a staging directory, run bird -c <bird.conf> -p.
// 2. Atomically swap staging → live config dir, then birdc configure (see BirdCtl).
package birdfmt
+79
View File
@@ -0,0 +1,79 @@
package birdfmt
import (
"fmt"
"net/netip"
"sort"
"strings"
)
// RenderExportFilterIPv4 renders a BIRD 2 filter that accepts IPv4 routes whose prefix
// is in prefixes (exact CIDR match via set membership), and rejects others.
func RenderExportFilterIPv4(filterName string, prefixes []netip.Prefix) (string, error) {
if strings.TrimSpace(filterName) == "" {
return "", fmt.Errorf("birdfmt: filter name is required")
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is4() {
continue
}
m := p.Masked()
uniq[m.String()] = m
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("filter ")
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) == 0 {
b.WriteString(" reject;\n")
} else {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
b.WriteString(" reject;\n")
}
b.WriteString("}\n")
return b.String(), nil
}
// RenderExportFilterIPv6 renders a BIRD 2 filter for IPv6 prefixes (CIDR set, then reject).
func RenderExportFilterIPv6(filterName string, prefixes []netip.Prefix) (string, error) {
if strings.TrimSpace(filterName) == "" {
return "", fmt.Errorf("birdfmt: filter name is required")
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is6() {
continue
}
m := p.Masked()
uniq[m.String()] = m
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("filter ")
b.WriteString(strings.TrimSpace(filterName))
b.WriteString(" {\n")
if len(keys) == 0 {
b.WriteString(" reject;\n")
} else {
b.WriteString(" if net ~ [ ")
b.WriteString(strings.Join(keys, ", "))
b.WriteString(" ] then accept;\n")
b.WriteString(" reject;\n")
}
b.WriteString("}\n")
return b.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"net/netip"
"os"
"strings"
"testing"
)
func TestRenderExportFilterIPv4_Empty(t *testing.T) {
got, err := RenderExportFilterIPv4("evobgp_x", nil)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "filter evobgp_x") {
t.Fatal(got)
}
if !strings.Contains(got, "reject;") {
t.Fatal(got)
}
}
func TestRenderExportFilterIPv4_SkipsNonV4(t *testing.T) {
v6 := netip.MustParsePrefix("2001:db8::/32")
got, err := RenderExportFilterIPv4("f", []netip.Prefix{v6})
if err != nil {
t.Fatal(err)
}
if strings.Contains(got, "2001:db8") {
t.Fatal("v6 prefix should be skipped in v4 filter")
}
}
func TestRenderExportFilterIPv6(t *testing.T) {
p := netip.MustParsePrefix("2001:db8::/32")
got, err := RenderExportFilterIPv6("evobgp_export_v6", []netip.Prefix{p, p})
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))
}
}
+47
View File
@@ -0,0 +1,47 @@
package birdfmt
import (
"fmt"
"strings"
)
// ManagedBanner returns a comment block marking EvoBGP-generated files (optional first line in bird.d/*.conf).
func ManagedBanner(revisionHint string) string {
rev := strings.TrimSpace(revisionHint)
var b strings.Builder
b.WriteString("# EvoBGP generated — do not edit by hand.\n")
if rev != "" {
b.WriteString("# Revision: ")
b.WriteString(rev)
b.WriteByte('\n')
}
return b.String()
}
// JoinFragments concatenates non-empty text blocks with a blank line between them.
func JoinFragments(parts ...string) string {
var out []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
if len(out) == 0 {
return ""
}
return strings.Join(out, "\n\n") + "\n"
}
// ValidateIncludePath rejects paths that could break out of the config directory.
func ValidateIncludePath(p string) error {
p = strings.TrimSpace(p)
if p == "" {
return fmt.Errorf("birdfmt: include path is empty")
}
if strings.Contains(p, "..") {
return fmt.Errorf("birdfmt: include path must not contain '..': %q", p)
}
return nil
}
+22
View File
@@ -0,0 +1,22 @@
package birdfmt
import "testing"
func TestValidateIncludePath(t *testing.T) {
if err := ValidateIncludePath("bird.d/x.conf"); err != nil {
t.Fatal(err)
}
if err := ValidateIncludePath("../etc/passwd"); err == nil {
t.Fatal("expected error for ..")
}
}
func TestJoinFragments(t *testing.T) {
got := JoinFragments("a", "", "b")
if got != "a\n\nb\n" {
t.Fatalf("%q", got)
}
if JoinFragments() != "" {
t.Fatal("empty join should be empty")
}
}
+95
View File
@@ -0,0 +1,95 @@
package birdfmt
import (
"fmt"
"strings"
)
// Directory and fragment file names for EvoBGP-generated includes (relative to bird.conf).
const (
DirBirdD = "bird.d"
FragmentPrefixesV4 = "evobgp_prefixes_v4.conf"
FragmentPrefixesV6 = "evobgp_prefixes_v6.conf"
FragmentFiltersV4 = "evobgp_filters_v4.conf"
FragmentFiltersV6 = "evobgp_filters_v6.conf"
FragmentPeers = "evobgp_peers.conf"
)
// FragmentIncludePath returns a POSIX include path such as bird.d/evobgp_prefixes_v4.conf.
func FragmentIncludePath(fragmentBaseName string) string {
if fragmentBaseName == "" {
return DirBirdD + "/"
}
return DirBirdD + "/" + fragmentBaseName
}
// StandardIncludeFragments is the recommended order: filters before peers that reference them.
func StandardIncludeFragments() []string {
return []string{
FragmentIncludePath(FragmentFiltersV4),
FragmentIncludePath(FragmentFiltersV6),
FragmentIncludePath(FragmentPrefixesV4),
FragmentIncludePath(FragmentPrefixesV6),
FragmentIncludePath(FragmentPeers),
}
}
// MainBirdConfOptions describes the top-level bird.conf skeleton EvoBGP expects beside bird.d/.
type MainBirdConfOptions struct {
// RouterID is the BIRD router id (IPv4 dotted quad recommended).
RouterID string
// Includes are paths as in include "…" (e.g. bird.d/evobgp_prefixes_v4.conf).
Includes []string
// Preamble is optional comment lines (each line prefixed with #), no trailing newline required.
Preamble string
}
// RenderMainBirdConf returns a BIRD 2 main config: device, direct, include lines.
// RouterID must be non-empty.
func RenderMainBirdConf(opts MainBirdConfOptions) (string, error) {
if strings.TrimSpace(opts.RouterID) == "" {
return "", fmt.Errorf("birdfmt: router id is required")
}
var b strings.Builder
pre := strings.TrimSpace(opts.Preamble)
if pre != "" {
for _, line := range strings.Split(pre, "\n") {
line = strings.TrimRight(line, "\r")
if line == "" {
b.WriteByte('\n')
continue
}
if !strings.HasPrefix(line, "#") {
b.WriteString("# ")
}
b.WriteString(line)
b.WriteByte('\n')
}
b.WriteByte('\n')
}
b.WriteString("router id ")
b.WriteString(strings.TrimSpace(opts.RouterID))
b.WriteString(";\n\n")
for _, inc := range opts.Includes {
inc = strings.TrimSpace(inc)
if inc == "" {
continue
}
b.WriteString("include \"")
b.WriteString(inc)
b.WriteString("\";\n")
}
if len(opts.Includes) > 0 {
b.WriteByte('\n')
}
b.WriteString(`protocol device {
}
protocol direct {
ipv4;
ipv6;
}
`)
return b.String(), nil
}
+55
View File
@@ -0,0 +1,55 @@
package birdfmt
import (
_ "embed"
"strings"
"testing"
)
//go:embed testdata/golden/main_bird_skeleton.golden
var goldenMainBirdSkeleton string
func TestRenderMainBirdConf_Golden(t *testing.T) {
got, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: StandardIncludeFragments(),
})
if err != nil {
t.Fatal(err)
}
want := strings.TrimSuffix(goldenMainBirdSkeleton, "\n")
got = strings.TrimSuffix(got, "\n")
if got != want {
t.Fatalf("golden mismatch\n--- got ---\n%s\n--- want ---\n%s", got, want)
}
}
func TestRenderMainBirdConf_Preamble(t *testing.T) {
got, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: nil,
Preamble: "operator note\n# already commented",
})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(got, "# operator note\n") {
t.Fatalf("expected preamble prefix, got:\n%s", got)
}
if !strings.Contains(got, "# # already commented") {
t.Fatal(got)
}
}
func TestRenderMainBirdConf_Errors(t *testing.T) {
_, err := RenderMainBirdConf(MainBirdConfOptions{})
if err == nil {
t.Fatal("expected error for empty router id")
}
}
func TestFragmentIncludePath(t *testing.T) {
if p := FragmentIncludePath(FragmentPrefixesV4); p != "bird.d/evobgp_prefixes_v4.conf" {
t.Fatal(p)
}
}
+52
View File
@@ -0,0 +1,52 @@
package birdfmt
import (
"bytes"
"context"
"fmt"
"os/exec"
"strings"
)
// ShowProtocols runs `birdc [-s socket] show protocols all` and returns stdout (BIRD 2).
func ShowProtocols(ctx context.Context, socket, birdcBin string) (string, error) {
if birdcBin == "" {
birdcBin = "birdc"
}
args := []string{"show", "protocols", "all"}
if s := strings.TrimSpace(socket); s != "" {
args = append([]string{"-s", s}, args...)
}
cmd := exec.CommandContext(ctx, birdcBin, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
return "", fmt.Errorf("birdc show protocols: %w: %s", err, msg)
}
return "", fmt.Errorf("birdc show protocols: %w", err)
}
return stdout.String(), nil
}
// CountEstablishedBGPSessions counts BGP protocol rows whose line contains "Established"
// (heuristic for `birdc show protocols` / `show protocols all` output).
func CountEstablishedBGPSessions(showProtocolsOutput string) int {
lines := strings.Split(showProtocolsOutput, "\n")
n := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "name") || strings.HasPrefix(strings.ToLower(line), "table") {
continue
}
if !strings.Contains(strings.ToLower(line), "bgp") {
continue
}
if strings.Contains(strings.ToLower(line), "established") {
n++
}
}
return n
}
+16
View File
@@ -0,0 +1,16 @@
package birdfmt
import "testing"
func TestCountEstablishedBGPSessions(t *testing.T) {
sample := `
BIRD 2.14 ready.
Name Proto Table State Since Info
device1 Device --- up 10:00:00
uplink4 BGP --- up 10:00:05 Established
uplink6 BGP --- start 10:00:06 Active
`
if got := CountEstablishedBGPSessions(sample); got != 1 {
t.Fatalf("got %d want 1", got)
}
}
+1
View File
@@ -23,6 +23,7 @@ func TestBirdScenarioPaths_Table(t *testing.T) {
"large_prefix_list",
"minimal",
"mixed_static_bgp",
"standard_layout",
"static_ipv4",
"with_include",
}
+72
View File
@@ -0,0 +1,72 @@
package birdfmt
import (
"net/netip"
"os"
"strings"
"testing"
)
// Ensures generator output matches the standard_layout scenario files (CI bird -p).
func TestStandardLayout_GeneratorMatchesFixtures(t *testing.T) {
p4 := netip.MustParsePrefix("203.0.113.0/24")
f4, err := RenderExportFilterIPv4("evobgp_export_v4", []netip.Prefix{p4})
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v4.conf", f4)
f6, err := RenderExportFilterIPv6("evobgp_export_v6", nil)
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_filters_v6.conf", f6)
staticV4 := RenderStaticIPv4Protocol("evobgp_prefixes_v4", []netip.Prefix{p4})
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v4.conf", staticV4)
staticV6 := RenderStaticIPv6Protocol("evobgp_prefixes_v6", nil)
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_prefixes_v6.conf", staticV6)
peer, err := RenderProtocolBGPIPv4(BGPPeerIPv4Options{
ProtocolName: "evobgp_peer_ci",
LocalIP: "192.0.2.1",
LocalASN: 65001,
NeighborIP: "192.0.2.2",
NeighborASN: 65002,
ExportFilter: "evobgp_export_v4",
})
if err != nil {
t.Fatal(err)
}
assertFileEquals(t, "testdata/scenarios/standard_layout/bird.d/evobgp_peers.conf", peer)
main, err := RenderMainBirdConf(MainBirdConfOptions{
RouterID: "192.0.2.1",
Includes: StandardIncludeFragments(),
Preamble: "tags: layout, include, filter, peer\nStandard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).",
})
if err != nil {
t.Fatal(err)
}
// Fixture has two header comment lines; RenderMainBirdConf adds # per line.
wantMain, err := os.ReadFile("testdata/scenarios/standard_layout/bird.conf")
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(main) != strings.TrimSpace(string(wantMain)) {
t.Fatalf("main bird.conf mismatch\n--- got ---\n%s\n--- want ---\n%s", main, wantMain)
}
}
func assertFileEquals(t *testing.T, path, content string) {
t.Helper()
want, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.TrimSpace(content) != strings.TrimSpace(string(want)) {
t.Fatalf("%s mismatch\n--- got ---\n%s\n--- want ---\n%s", path, content, string(want))
}
}
+31
View File
@@ -37,3 +37,34 @@ func RenderStaticIPv4Protocol(protocolName string, prefixes []netip.Prefix) stri
b.WriteString("}\n")
return b.String()
}
// RenderStaticIPv6Protocol renders a BIRD 2 `protocol static` block for IPv6 prefixes.
func RenderStaticIPv6Protocol(protocolName string, prefixes []netip.Prefix) string {
if protocolName == "" {
protocolName = "evobgp_static_v6"
}
uniq := make(map[string]netip.Prefix)
for _, p := range prefixes {
if !p.Addr().Is6() {
continue
}
uniq[p.String()] = p.Masked()
}
keys := make([]string, 0, len(uniq))
for k := range uniq {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString("protocol static ")
b.WriteString(protocolName)
b.WriteString(" {\n ipv6;\n")
for _, k := range keys {
b.WriteString(" route ")
b.WriteString(k)
b.WriteString(" unreachable;\n")
}
b.WriteString("}\n")
return b.String()
}
+11
View File
@@ -32,3 +32,14 @@ func TestRenderStaticIPv4Protocol_Empty(t *testing.T) {
t.Fatal(got)
}
}
func TestRenderStaticIPv6Protocol_Dedup(t *testing.T) {
p := netip.MustParsePrefix("2001:db8::/32")
got := RenderStaticIPv6Protocol("evobgp_v6", []netip.Prefix{p, p})
if !strings.Contains(got, "2001:db8::/32") {
t.Fatal(got)
}
if !strings.Contains(got, "ipv6") {
t.Fatal(got)
}
}
@@ -0,0 +1,15 @@
router id 192.0.2.1;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
include "bird.d/evobgp_prefixes_v4.conf";
include "bird.d/evobgp_prefixes_v6.conf";
include "bird.d/evobgp_peers.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,18 @@
# tags: layout, include, filter, peer
# Standard EvoBGP layout: main skeleton + bird.d fragments (matches StandardIncludeFragments).
router id 192.0.2.1;
include "bird.d/evobgp_filters_v4.conf";
include "bird.d/evobgp_filters_v6.conf";
include "bird.d/evobgp_prefixes_v4.conf";
include "bird.d/evobgp_prefixes_v6.conf";
include "bird.d/evobgp_peers.conf";
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
@@ -0,0 +1,4 @@
filter evobgp_export_v4 {
if net ~ [ 203.0.113.0/24 ] then accept;
reject;
}
@@ -0,0 +1,3 @@
filter evobgp_export_v6 {
reject;
}
@@ -0,0 +1,8 @@
protocol bgp evobgp_peer_ci {
local 192.0.2.1 as 65001;
neighbor 192.0.2.2 as 65002;
ipv4 {
import all;
export filter evobgp_export_v4;
};
}
@@ -0,0 +1,4 @@
protocol static evobgp_prefixes_v4 {
ipv4;
route 203.0.113.0/24 unreachable;
}
@@ -0,0 +1,3 @@
protocol static evobgp_prefixes_v6 {
ipv6;
}
+51
View File
@@ -0,0 +1,51 @@
package bundle
import (
"crypto/ed25519"
"crypto/rand"
"testing"
)
func TestBuildVerifyRoundtrip(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
frag := map[string]string{
"bird.conf": "router id 192.0.2.1;\n\nprotocol device {\n}\n\nprotocol direct {\n ipv4;\n ipv6;\n}\n",
}
raw, err := BuildGzippedTar("rev-1", "sp-1", frag, priv)
if err != nil {
t.Fatal(err)
}
pub := priv.Public().(ed25519.PublicKey)
v, err := VerifyGzippedTar(raw, pub)
if err != nil {
t.Fatal(err)
}
if v.Manifest.RevisionID != "rev-1" {
t.Fatalf("revision %q", v.Manifest.RevisionID)
}
if len(v.Files) != 1 {
t.Fatalf("files %d", len(v.Files))
}
}
func TestVerifyWrongKey(t *testing.T) {
_, privA, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
_, privB, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
raw, err := BuildGzippedTar("rev-x", "sp-x", map[string]string{"a.conf": "x"}, privA)
if err != nil {
t.Fatal(err)
}
pubB := privB.Public().(ed25519.PublicKey)
if _, err := VerifyGzippedTar(raw, pubB); err == nil {
t.Fatal("expected signature error with wrong public key")
}
}
+46
View File
@@ -0,0 +1,46 @@
package bundle
import (
"fmt"
"os"
pathpkg "path"
"path/filepath"
"strings"
)
// WriteExtractedFiles writes payload files from a verified bundle under root (directories created as needed).
func WriteExtractedFiles(root string, v *VerifiedContents) error {
root = filepath.Clean(root)
for _, fe := range v.Manifest.Files {
data, ok := v.Files[fe.Path]
if !ok {
return fmt.Errorf("bundle: missing file %q", fe.Path)
}
rel := pathpkg.Clean(strings.TrimPrefix(fe.Path, "/"))
if rel == "." || strings.HasPrefix(rel, "..") {
return fmt.Errorf("bundle: unsafe path %q", fe.Path)
}
dest := filepath.Join(root, filepath.FromSlash(rel))
relToRoot, err := filepath.Rel(root, dest)
if err != nil || strings.HasPrefix(relToRoot, "..") {
return fmt.Errorf("bundle: path escapes root: %q", fe.Path)
}
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
return err
}
if err := os.WriteFile(dest, data, 0o644); err != nil {
return err
}
}
return nil
}
// FindMainBirdConf returns the first path ending with bird.conf from extracted manifest order.
func (v *VerifiedContents) FindMainBirdConf() string {
for _, fe := range v.Manifest.Files {
if strings.HasSuffix(fe.Path, "bird.conf") {
return fe.Path
}
}
return ""
}
+131
View File
@@ -0,0 +1,131 @@
package bundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"path"
"sort"
"strings"
"time"
)
// Manifest describes bundle contents for evobgp-node verification.
type Manifest struct {
RevisionID string `json:"revision_id"`
SpeakerID string `json:"speaker_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
Files []FileEntry `json:"files"`
Algorithm string `json:"signature_algorithm"`
PublicKeyB64 string `json:"public_key_base64"`
}
// FileEntry is one file inside the bundle archive.
type FileEntry struct {
Path string `json:"path"`
SHA256 string `json:"sha256"`
}
const sigFileName = "bundle.sig"
const manifestName = "manifest.json"
// BuildGzippedTar builds a .tar.gz with manifest.json, bundle.sig (Ed25519 over manifest JSON), and payload files.
func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, priv ed25519.PrivateKey) ([]byte, error) {
if len(priv) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("bundle: invalid ed25519 private key size")
}
pub := priv.Public().(ed25519.PublicKey)
norm := make(map[string]string, len(fragments))
for p, content := range fragments {
p = path.Clean(strings.TrimPrefix(p, "/"))
if p == "." || strings.HasPrefix(p, "..") {
return nil, fmt.Errorf("bundle: invalid path %q", p)
}
norm[p] = content
}
var files []FileEntry
var payload [][]byte
keys := sortedStringKeys(norm)
for _, p := range keys {
data := []byte(norm[p])
h := sha256.Sum256(data)
files = append(files, FileEntry{Path: p, SHA256: fmt.Sprintf("%x", h[:])})
payload = append(payload, data)
}
m := Manifest{
RevisionID: revisionID,
SpeakerID: speakerID,
CreatedAt: time.Now().UTC(),
Files: files,
Algorithm: "ed25519",
PublicKeyB64: base64.StdEncoding.EncodeToString(pub),
}
manifestJSON, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, err
}
sig := ed25519.Sign(priv, manifestJSON)
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
tw := tar.NewWriter(gw)
add := func(name string, body []byte) error {
hdr := &tar.Header{
Name: name,
Mode: 0o644,
Size: int64(len(body)),
ModTime: time.Now().UTC(),
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
_, err := tw.Write(body)
return err
}
if err := add(manifestName, manifestJSON); err != nil {
_ = tw.Close()
_ = gw.Close()
return nil, err
}
if err := add(sigFileName, sig); err != nil {
_ = tw.Close()
_ = gw.Close()
return nil, err
}
i := 0
for _, p := range keys {
if err := add(p, payload[i]); err != nil {
_ = tw.Close()
_ = gw.Close()
return nil, err
}
i++
}
if err := tw.Close(); err != nil {
_ = gw.Close()
return nil, err
}
if err := gw.Close(); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
func sortedStringKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+110
View File
@@ -0,0 +1,110 @@
package bundle
import (
"archive/tar"
"bytes"
"compress/gzip"
"crypto/ed25519"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"path"
"strings"
)
// VerifiedContents is the result of verifying a downloaded bundle on evobgp-node.
type VerifiedContents struct {
Manifest Manifest
Files map[string][]byte
}
// VerifyGzippedTar checks Ed25519 signature over manifest.json and SHA-256 of each file.
func VerifyGzippedTar(bundle []byte, pub ed25519.PublicKey) (*VerifiedContents, error) {
gr, err := gzip.NewReader(bytes.NewReader(bundle))
if err != nil {
return nil, err
}
defer gr.Close()
var manifestRaw []byte
var sig []byte
files := make(map[string][]byte)
tr := tar.NewReader(gr)
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
name := path.Clean(hdr.Name)
if strings.HasPrefix(name, "..") {
return nil, fmt.Errorf("bundle: illegal tar entry %q", hdr.Name)
}
data, err := io.ReadAll(tr)
if err != nil {
return nil, err
}
switch name {
case manifestName:
manifestRaw = data
case sigFileName:
sig = data
default:
files[name] = data
}
}
if len(manifestRaw) == 0 || len(sig) == 0 {
return nil, fmt.Errorf("bundle: missing manifest or signature")
}
if !ed25519.Verify(pub, manifestRaw, sig) {
return nil, fmt.Errorf("bundle: ed25519 signature mismatch")
}
var m Manifest
if err := json.Unmarshal(manifestRaw, &m); err != nil {
return nil, err
}
for _, fe := range m.Files {
body, ok := files[fe.Path]
if !ok {
return nil, fmt.Errorf("bundle: missing file %q", fe.Path)
}
h := sha256.Sum256(body)
if fmt.Sprintf("%x", h[:]) != fe.SHA256 {
return nil, fmt.Errorf("bundle: checksum mismatch for %q", fe.Path)
}
}
return &VerifiedContents{Manifest: m, Files: files}, nil
}
// ParsePublicKeyHex decodes a 64-char hex Ed25519 public key (32 bytes).
func ParsePublicKeyHex(s string) (ed25519.PublicKey, error) {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimPrefix(s, "0x"), "0X"))
raw, err := hex.DecodeString(s)
if err != nil {
return nil, fmt.Errorf("bundle: hex decode: %w", err)
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bundle: want %d-byte ed25519 public key", ed25519.PublicKeySize)
}
return ed25519.PublicKey(raw), nil
}
// PublicKeyFromManifestBase64 uses the key embedded in the signed manifest (cross-check with expected pubkey optional).
func PublicKeyFromManifestBase64(m *Manifest) (ed25519.PublicKey, error) {
if m.PublicKeyB64 == "" {
return nil, fmt.Errorf("bundle: manifest missing public_key_base64")
}
raw, err := base64.StdEncoding.DecodeString(m.PublicKeyB64)
if err != nil {
return nil, err
}
if len(raw) != ed25519.PublicKeySize {
return nil, fmt.Errorf("bundle: bad public key length")
}
return ed25519.PublicKey(raw), nil
}
+35
View File
@@ -0,0 +1,35 @@
// Package config loads control-plane settings from the environment (see architecture plan §1).
package config
import (
"os"
"strings"
)
// Env holds shared process configuration for cmd/* entrypoints.
type Env struct {
HTTPAddr string
ServiceName string
GitSHA string
// DatabaseURL is the PostgreSQL DSN when SQL-backed repositories are wired (empty in dev).
DatabaseURL string
// BrokerURL is NATS/Redis when the reference profile uses a message broker (empty in microVPS).
BrokerURL string
}
// Load reads EVOBGP_* environment variables with safe defaults.
func Load() Env {
e := Env{
HTTPAddr: ":8080",
}
if v := strings.TrimSpace(os.Getenv("EVOBGP_HTTP_ADDR")); v != "" {
e.HTTPAddr = v
}
if v := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE")); v != "" {
e.ServiceName = v
}
e.GitSHA = strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
e.DatabaseURL = strings.TrimSpace(os.Getenv("EVOBGP_DATABASE_URL"))
e.BrokerURL = strings.TrimSpace(os.Getenv("EVOBGP_BROKER_URL"))
return e
}
+9
View File
@@ -0,0 +1,9 @@
package db
import "evobgp/migrations"
// PostgresMigrations and SQLiteMigrations expose embedded SQL files for migrate runners (plan §1, §11).
var (
PostgresMigrations = migrations.Postgres
SQLiteMigrations = migrations.SQLite
)
+23
View File
@@ -0,0 +1,23 @@
// Package deploy delivers generated BIRD fragments to evobgp-agent / publishes signed bundles for evobgp-node.
package deploy
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. Reference deployment: worker after render, talks to agent API or shared volume.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-deploy: started (stub; deploy jobs push configs and update bundle pointers)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-deploy: stopped")
return
case <-t.C:
}
}
}
+10
View File
@@ -0,0 +1,10 @@
// Package domain holds pure types and invariants without I/O (plan §1).
package domain
// Module kinds for prefix sources (plan §6, §7.3).
const (
ModuleASPrefixes = "AS_PREFIXES"
ModuleCDNCIDRs = "CDN_CIDRS"
ModuleDomains = "DOMAINS"
ModuleIPRanges = "IP_RANGES"
)
+135
View File
@@ -0,0 +1,135 @@
package httpapi
import (
"context"
"net/http"
"strings"
)
type ctxKey int
const authCtxKey ctxKey = 1
// Auth holds resolved API identity for a request.
type Auth struct {
TenantID string
Role string // viewer, editor, operator, node
Token string
}
func authFromContext(ctx context.Context) (Auth, bool) {
a, ok := ctx.Value(authCtxKey).(Auth)
return a, ok
}
type apiKeyRecord struct {
token string
tenantID string
role string
}
func parseAPIKeysSpec(spec string) []apiKeyRecord {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []apiKeyRecord
for _, part := range strings.Split(spec, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
fields := strings.Split(part, "|")
if len(fields) != 3 {
continue
}
out = append(out, apiKeyRecord{
token: strings.TrimSpace(fields[0]),
tenantID: strings.TrimSpace(fields[1]),
role: strings.TrimSpace(fields[2]),
})
}
return out
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.insecureDev {
h := r.Header.Get("Authorization")
const p = "Bearer "
if strings.HasPrefix(h, p) {
tok := strings.TrimSpace(strings.TrimPrefix(h, p))
if tok == "dev" {
if a, ok := s.devAuth(); ok {
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
return
}
}
}
}
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing or invalid bearer token")
return
}
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
var matched *apiKeyRecord
for i := range s.apiKeys {
if s.apiKeys[i].token == raw {
matched = &s.apiKeys[i]
break
}
}
if matched == nil {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
return
}
a := Auth{TenantID: matched.tenantID, Role: matched.role, Token: raw}
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
next.ServeHTTP(w, r)
})
}
func (s *Server) devAuth() (Auth, bool) {
tid, _, _, _, _ := s.store.DemoIDs()
if tid == "" {
return Auth{}, false
}
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
}
func roleLevel(role string) int {
switch strings.ToLower(role) {
case "viewer":
return 1
case "editor":
return 2
case "operator":
return 3
default:
return 0
}
}
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
if strings.ToLower(a.Role) == "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
return false
}
if roleLevel(a.Role) < roleLevel(need) {
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
return false
}
return true
}
func (s *Server) requireNode(w http.ResponseWriter, a Auth) bool {
if strings.ToLower(a.Role) != "node" {
writeProblem(w, http.StatusForbidden, "Forbidden", "node role required")
return false
}
return true
}
+48
View File
@@ -0,0 +1,48 @@
package httpapi
import (
"net/http"
"strings"
)
func parseCORSOrigins(spec string) []string {
spec = strings.TrimSpace(spec)
if spec == "" {
return nil
}
var out []string
for _, p := range strings.Split(spec, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
func (s *Server) withCORS(h http.Handler) http.Handler {
if len(s.corsOrigins) == 0 {
return h
}
allowed := make(map[string]struct{}, len(s.corsOrigins))
for _, o := range s.corsOrigins {
allowed[o] = struct{}{}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" {
if _, ok := allowed[origin]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Add("Vary", "Origin")
}
}
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, Idempotency-Key, Accept, X-Tenant-Id")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
h.ServeHTTP(w, r)
})
}
+36
View File
@@ -0,0 +1,36 @@
package httpapi
import (
"encoding/json"
"net/http"
)
// Problem is RFC 9457 application/problem+json.
type Problem struct {
Type string `json:"type,omitempty"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
}
func writeProblem(w http.ResponseWriter, status int, title, detail string) {
w.Header().Set("Content-Type", "application/problem+json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(Problem{
Type: "about:blank",
Title: title,
Status: status,
Detail: detail,
})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeNoContent(w http.ResponseWriter) {
w.WriteHeader(http.StatusNoContent)
}
+673
View File
@@ -0,0 +1,673 @@
package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"os"
"sort"
"strconv"
"strings"
"time"
"evobgp/internal/bundle"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
// Handler returns the root HTTP handler (system routes public; rest under /v1/ authenticated).
func (s *Server) Handler() http.Handler {
v1 := http.NewServeMux()
s.registerV1(v1)
wrappedV1 := http.StripPrefix("/v1", v1)
s.mux.Handle("GET /metrics", observability.MetricsHandler())
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
s.mux.Handle("/v1/", s.authMiddleware(wrappedV1))
return s.withCORS(observability.HTTPMiddleware(s.mux))
}
// BundlePublicKeyBase64 returns the Ed25519 public key for verifying bundles (share with evobgp-node).
func (s *Server) BundlePublicKeyBase64() string {
pub := s.bundlePriv.Public().(ed25519.PublicKey)
return base64.StdEncoding.EncodeToString(pub)
}
func (s *Server) registerRoutes() {
// routes attached in Handler()
}
func (s *Server) registerV1(m *http.ServeMux) {
m.HandleFunc("GET /modules", s.handleListModules)
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
m.HandleFunc("GET /peers", s.handleListPeers)
m.HandleFunc("GET /speakers", s.handleListSpeakers)
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
m.HandleFunc("GET /revisions", s.handleListRevisions)
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
m.HandleFunc("GET /revisions/{revision_a}/diff/{revision_b}", s.handleRevisionDiff)
m.HandleFunc("POST /revisions/{revision_id}/rollback", s.handleRevisionRollback)
m.HandleFunc("POST /apply", s.handleApply)
m.HandleFunc("POST /speakers/{id}/apply", s.handleSpeakerApply)
m.HandleFunc("POST /bird/reload", s.handleBirdReload)
m.HandleFunc("GET /jobs", s.handleListJobs)
m.HandleFunc("GET /jobs/{job_id}", s.handleGetJob)
m.HandleFunc("POST /jobs/{job_id}/cancel", s.handleCancelJob)
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)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
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"}})
}
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
sha := strings.TrimSpace(os.Getenv("EVOBGP_GIT_SHA"))
if sha == "" {
sha = "unknown"
}
writeJSON(w, http.StatusOK, map[string]string{"api_version": "0.1.0", "git_sha": sha})
}
func moduleJSON(mod *store.Module) map[string]any {
m := map[string]any{
"id": mod.ID,
"type": mod.Type,
"name": mod.Name,
"enabled": mod.Enabled,
"priority": mod.Priority,
"refresh_interval_sec": mod.RefreshIntervalSec,
"cron_expr": mod.CronExpr,
}
if mod.DefaultCommunityID != nil {
m["default_community_id"] = *mod.DefaultCommunityID
} else {
m["default_community_id"] = nil
}
if mod.DohProfileID != nil {
m["doh_profile_id"] = *mod.DohProfileID
} else {
m["doh_profile_id"] = nil
}
return m
}
func peerJSON(p *store.BGPPeer) map[string]any {
m := map[string]any{
"id": p.ID,
"name": p.Name,
"neighbor": p.Neighbor,
"session_state": p.SessionState,
}
if p.SpeakerID != nil {
m["bgp_speaker_id"] = *p.SpeakerID
} else {
m["bgp_speaker_id"] = nil
}
return m
}
func speakerJSON(sp *store.Speaker) map[string]any {
m := map[string]any{
"id": sp.ID,
"role": sp.Role,
"endpoint": sp.Endpoint,
}
if sp.LastAppliedRevisionID != nil {
m["last_applied_revision_id"] = *sp.LastAppliedRevisionID
} else {
m["last_applied_revision_id"] = nil
}
return m
}
func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mods := s.store.ListModules(a.TenantID)
items := make([]map[string]any, 0, len(mods))
for _, mod := range mods {
items = append(items, moduleJSON(mod))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
writeJSON(w, http.StatusOK, moduleJSON(mod))
}
func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
peers := s.store.ListPeers(a.TenantID)
items := make([]map[string]any, 0, len(peers))
for _, p := range peers {
items = append(items, peerJSON(p))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
speakers := s.store.ListSpeakersForTenant(a.TenantID)
items := make([]map[string]any, 0, len(speakers))
for _, sp := range speakers {
items = append(items, speakerJSON(sp))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": nil, "has_more": false,
})
}
func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
moduleID := r.PathValue("module_id")
mod, err := s.store.GetModule(a.TenantID, moduleID)
if err != nil {
if err == store.ErrNotFound || err == store.ErrTenantScope {
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
return
}
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
if mod.Type == "IP_RANGES" {
writeNoContent(w)
return
}
idem := r.Header.Get("Idempotency-Key")
var idemPtr *string
if strings.TrimSpace(idem) != "" {
idem = strings.TrimSpace(idem)
idemPtr = &idem
}
mid := mod.ID
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindModuleRefresh, idemPtr, &mid, map[string]any{"module_id": moduleID})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !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")
moduleID := r.URL.Query().Get("module_id")
items, next, more := s.store.ListRevisions(a.TenantID, moduleID, cursor, limit)
out := make([]map[string]any, 0, len(items))
for _, rev := range items {
out = append(out, revisionJSON(rev))
}
writeJSON(w, http.StatusOK, map[string]any{
"items": out, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
func revisionJSON(rev *store.Revision) map[string]any {
m := map[string]any{
"id": rev.ID,
"content_hash": rev.ContentHash,
"created_at": rev.CreatedAt.UTC().Format(time.RFC3339Nano),
"materialized_prefix_count": rev.MaterializedPrefixCount,
}
if rev.ModuleID != "" {
m["module_id"] = rev.ModuleID
} else {
m["module_id"] = nil
}
if rev.ParentRevisionID != nil {
m["parent_revision_id"] = *rev.ParentRevisionID
} else {
m["parent_revision_id"] = nil
}
return m
}
func strPtrOrNull(s string) any {
if s == "" {
return nil
}
return s
}
func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
writeJSON(w, http.StatusOK, revisionJSON(rev))
}
func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
acc := r.Header.Get("Accept")
if strings.Contains(acc, "text/plain") && !strings.Contains(acc, "application/json") {
var b strings.Builder
for _, k := range sortedFragmentKeys(rev.PreviewFragments) {
b.WriteString("# --- ")
b.WriteString(k)
b.WriteString(" ---\n")
b.WriteString(rev.PreviewFragments[k])
b.WriteByte('\n')
}
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(b.String()))
return
}
obj := make(map[string]any, len(rev.PreviewFragments)+1)
for k, v := range rev.PreviewFragments {
obj[k] = v
}
writeJSON(w, http.StatusOK, obj)
}
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
writeJSON(w, http.StatusOK, d)
}
func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
revID := r.PathValue("revision_id")
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindRevisionRollback, idemPtr, nil, map[string]any{
"source_revision_id": revID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
var body struct {
RevisionID string `json:"revision_id"`
Strategy string `json:"strategy"`
DryRun bool `json:"dry_run"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
revID := strings.TrimSpace(body.RevisionID)
if revID == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required")
return
}
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
if body.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true, "revision_id": revID})
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
"revision_id": revID,
"strategy": body.Strategy,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
spkID := r.PathValue("id")
if _, err := s.store.GetSpeaker(a.TenantID, spkID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
var body struct {
RevisionID string `json:"revision_id"`
DryRun bool `json:"dry_run"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
revID := strings.TrimSpace(body.RevisionID)
if revID == "" {
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "revision_id required")
return
}
if _, err := s.store.GetRevision(a.TenantID, revID); err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
if body.DryRun {
writeJSON(w, http.StatusOK, map[string]any{"dry_run": true})
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindDeployApply, idemPtr, nil, map[string]any{
"revision_id": revID,
"speaker_id": spkID,
})
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
snap := j.Snapshot()
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
}
func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if strings.ToLower(a.Role) != "operator" {
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
return
}
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
var idemPtr *string
if idem != "" {
idemPtr = &idem
}
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Location", "/v1/jobs/"+j.ID)
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": j.ID, "status": "queued"})
}
func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
status := r.URL.Query().Get("status")
kind := r.URL.Query().Get("kind")
cursor := r.URL.Query().Get("cursor")
list, next, more := s.jobs.List(a.TenantID, status, kind, cursor, limit)
items := make([]map[string]any, 0, len(list))
for _, j := range list {
items = append(items, j.Snapshot())
}
writeJSON(w, http.StatusOK, map[string]any{
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
})
}
func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "viewer") {
return
}
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
writeJSON(w, http.StatusOK, j.Snapshot())
}
func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireAtLeast(w, a, "editor") {
return
}
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "job not found")
return
}
writeJSON(w, http.StatusAccepted, j.Snapshot())
}
func (s *Server) handleNodeLatestRevision(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
sid := r.PathValue("speaker_id")
sp, err := s.store.GetSpeakerAnyTenant(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
if sp.TenantID != a.TenantID {
writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope")
return
}
rid, at, err := s.store.LatestPublishedRevision(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "no published revision")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"revision_id": rid, "published_at": at.UTC().Format(time.RFC3339Nano),
})
}
func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
sid := r.PathValue("speaker_id")
rid := r.PathValue("revision_id")
sp, err := s.store.GetSpeakerAnyTenant(sid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "speaker not found")
return
}
if sp.TenantID != a.TenantID {
writeProblem(w, http.StatusForbidden, "Forbidden", "speaker not in tenant scope")
return
}
rev, err := s.store.GetRevision(a.TenantID, rid)
if err != nil {
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
return
}
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
if err != nil {
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
return
}
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Disposition", `attachment; filename="bundle.tar.gz"`)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(tgz)
}
func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
a, ok := authFromContext(r.Context())
if !ok {
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
return
}
if !s.requireNode(w, a) {
return
}
var req map[string]any
_ = json.NewDecoder(r.Body).Decode(&req)
writeJSON(w, http.StatusOK, map[string]any{
"status": "accepted",
"message": "enrollment stub; operator approval required in production",
})
}
func sortedFragmentKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
+78
View File
@@ -0,0 +1,78 @@
package httpapi
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"errors"
"net/http"
"strings"
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/store"
)
// Server implements EvoBGP control-plane HTTP API (subset focused on jobs, deploy, node bundle).
type Server struct {
store *store.Memory
jobs *jobs.Registry
bundlePriv ed25519.PrivateKey
apiKeys []apiKeyRecord
insecureDev bool
corsOrigins []string
mux *http.ServeMux
}
// 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).
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()
}
wk := &jobs.Worker{Store: mem}
reg := jobs.NewRegistry(wk.Process)
var priv ed25519.PrivateKey
if strings.TrimSpace(opts.BundleSeedHex) != "" {
seed, err := hex.DecodeString(strings.TrimSpace(opts.BundleSeedHex))
if err != nil {
return nil, err
}
if len(seed) != ed25519.SeedSize {
return nil, errors.New("httpapi: BundleSeedHex must decode to 32 bytes")
}
priv = ed25519.NewKeyFromSeed(seed)
} else {
_, priv, _ = ed25519.GenerateKey(rand.Reader)
}
s := &Server{
store: mem,
jobs: reg,
bundlePriv: priv,
apiKeys: parseAPIKeysSpec(opts.APIKeys),
insecureDev: opts.InsecureDev && opts.SeedDemo,
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
}
observability.RegisterStoreMetrics(mem)
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 }
+228
View File
@@ -0,0 +1,228 @@
package httpapi
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"evobgp/internal/jobs"
"evobgp/internal/signing"
)
const testBundleSeed = "0101010101010101010101010101010101010101010101010101010101010101"
func TestAPIRefreshApplyJobsBundle(t *testing.T) {
srv, err := New(Options{
InsecureDev: true,
SeedDemo: true,
BundleSeedHex: testBundleSeed,
})
if err != nil {
t.Fatal(err)
}
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
client := ts.Client()
base := ts.URL
t.Run("prometheus metrics", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/metrics", nil)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, _ := io.ReadAll(resp.Body)
s := string(raw)
for _, needle := range []string{
"evobgp_materialized_prefixes_max",
"evobgp_bgp_peers_configured_total",
"evobgp_http_requests_total", // incremented by this scrape request
} {
if !strings.Contains(s, needle) {
t.Fatalf("metrics body missing %q", needle)
}
}
})
t.Run("refresh IP_RANGES no op", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNoContent {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("refresh CDN queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modCDN+"/refresh", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("preview revision", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/revisions/"+rev+"/preview", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
})
t.Run("list modules peers speakers", func(t *testing.T) {
for _, path := range []string{"/v1/modules", "/v1/peers", "/v1/speakers"} {
req, _ := http.NewRequest(http.MethodGet, base+path, nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s status %d: %s", path, resp.StatusCode, b)
}
var body struct {
Items []map[string]any `json:"items"`
}
if err := json.Unmarshal(b, &body); err != nil {
t.Fatalf("%s json: %v", path, err)
}
if len(body.Items) < 1 {
t.Fatalf("%s expected items", path)
}
}
})
t.Run("rollback queues job", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
req.Header.Set("Authorization", "Bearer opkey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
t.Run("apply all speakers", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodPost, base+"/v1/apply", strings.NewReader(`{"revision_id":"`+rev+`"}`))
req.Header.Set("Authorization", "Bearer opkey")
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusAccepted {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
var body struct {
JobID string `json:"job_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&body)
waitJob(t, client, base, "opkey", body.JobID)
})
pubB64 := srv.BundlePublicKeyBase64()
pubBytes, err := base64.StdEncoding.DecodeString(pubB64)
if err != nil {
t.Fatal(err)
}
t.Run("node bundle roundtrip verify", func(t *testing.T) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/speakers/"+speaker+"/bundle/"+rev, nil)
req.Header.Set("Authorization", "Bearer nodekey")
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(resp.Body)
t.Fatalf("status %d: %s", resp.StatusCode, b)
}
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
_, err = signing.VerifyGzippedTar(raw, ed25519.PublicKey(pubBytes))
if err != nil {
t.Fatal(err)
}
})
}
func waitJob(t *testing.T, client *http.Client, base, token, jobID string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
req, _ := http.NewRequest(http.MethodGet, base+"/v1/jobs/"+jobID, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var body struct {
Status string `json:"status"`
}
_ = json.Unmarshal(b, &body)
if body.Status == jobs.StatusSucceeded || body.Status == jobs.StatusFailed {
if body.Status != jobs.StatusSucceeded {
t.Fatalf("job %s status %s", jobID, body.Status)
}
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("job %s did not complete", jobID)
}
+23
View File
@@ -0,0 +1,23 @@
// Package ingest fetches CDN lists, resolves DoH domains, and writes normalized prefixes into the database.
package ingest
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. Reference deployment: separate process consuming the job queue.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-ingest: started (stub; ingest workers dequeue from broker or job_audit)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-ingest: stopped")
return
case <-t.C:
}
}
}
+5
View File
@@ -0,0 +1,5 @@
package jobs
import "errors"
var ErrNotFound = errors.New("jobs: not found")
+285
View File
@@ -0,0 +1,285 @@
package jobs
import (
"fmt"
"sort"
"sync"
"time"
"github.com/google/uuid"
)
// Status values align with OpenAPI JobStatus and DB constraint job_audit_status_chk.
const (
StatusQueued = "queued"
StatusRunning = "running"
StatusSucceeded = "succeeded"
StatusFailed = "failed"
StatusCancelled = "cancelled"
)
// Job is the API-facing job model (maps to job_audit).
type Job struct {
ID string
TenantID string
Kind string
Status string
IdempotencyKey *string
ModuleID *string
CreatedAt time.Time
StartedAt *time.Time
FinishedAt *time.Time
Error *string
ProgressPct *int16
Meta map[string]any
cancelRequested bool
mu sync.Mutex
}
func (j *Job) MarkRunning() {
j.mu.Lock()
defer j.mu.Unlock()
if j.Status != StatusQueued {
return
}
now := time.Now().UTC()
j.StartedAt = &now
j.Status = StatusRunning
}
func (j *Job) Succeed() {
j.mu.Lock()
defer j.mu.Unlock()
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusSucceeded
}
func (j *Job) Fail(msg string) {
j.mu.Lock()
defer j.mu.Unlock()
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusFailed
j.Error = &msg
}
func (j *Job) MarkCancelled() {
j.mu.Lock()
defer j.mu.Unlock()
if j.Status == StatusSucceeded || j.Status == StatusFailed || j.Status == StatusCancelled {
return
}
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusCancelled
}
func (j *Job) IsCancelRequested() bool {
j.mu.Lock()
defer j.mu.Unlock()
return j.cancelRequested
}
func (j *Job) RequestCancel() bool {
j.mu.Lock()
defer j.mu.Unlock()
j.cancelRequested = true
if j.Status == StatusQueued {
now := time.Now().UTC()
j.FinishedAt = &now
j.Status = StatusCancelled
return true
}
return false
}
// Snapshot returns a consistent view for JSON serialization (safe under concurrent worker updates).
func (j *Job) mergeMeta(kv map[string]any) {
j.mu.Lock()
defer j.mu.Unlock()
if j.Meta == nil {
j.Meta = map[string]any{}
}
for k, v := range kv {
j.Meta[k] = v
}
}
// statusLocked is used by the worker defer for metrics (any stable terminal or in-flight status).
func (j *Job) statusLocked() string {
j.mu.Lock()
defer j.mu.Unlock()
return j.Status
}
func (j *Job) Snapshot() map[string]any {
j.mu.Lock()
defer j.mu.Unlock()
metaCopy := make(map[string]any, len(j.Meta))
for k, v := range j.Meta {
metaCopy[k] = v
}
m := map[string]any{
"job_id": j.ID, "kind": j.Kind, "status": j.Status,
"created_at": j.CreatedAt.UTC().Format(time.RFC3339Nano),
"meta": metaCopy,
}
if j.IdempotencyKey != nil {
m["idempotency_key"] = *j.IdempotencyKey
} else {
m["idempotency_key"] = nil
}
if j.StartedAt != nil {
m["started_at"] = j.StartedAt.UTC().Format(time.RFC3339Nano)
} else {
m["started_at"] = nil
}
if j.FinishedAt != nil {
m["finished_at"] = j.FinishedAt.UTC().Format(time.RFC3339Nano)
} else {
m["finished_at"] = nil
}
if j.Error != nil {
m["error"] = *j.Error
} else {
m["error"] = nil
}
return m
}
// Registry tracks jobs in memory (microVPS-style single process; swap for PG + SKIP LOCKED later).
type Registry struct {
mu sync.RWMutex
byID map[string]*Job
byIdempo map[idempoKey]*Job
workerStart func(j *Job)
}
type idempoKey struct {
tenant string
key string
}
func NewRegistry(workerStart func(j *Job)) *Registry {
return &Registry{
byID: make(map[string]*Job),
byIdempo: make(map[idempoKey]*Job),
workerStart: workerStart,
}
}
// Enqueue creates a job or returns an existing one for the same idempotency key.
func (r *Registry) Enqueue(tenantID, kind string, idempotencyKey *string, moduleID *string, meta map[string]any) (*Job, bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
if idempotencyKey != nil && *idempotencyKey != "" {
k := idempoKey{tenant: tenantID, key: *idempotencyKey}
if existing, ok := r.byIdempo[k]; ok {
return existing, false, nil
}
}
j := &Job{
ID: uuid.NewString(),
TenantID: tenantID,
Kind: kind,
Status: StatusQueued,
IdempotencyKey: idempotencyKey,
ModuleID: moduleID,
CreatedAt: time.Now().UTC(),
Meta: cloneMeta(meta),
}
if idempotencyKey != nil && *idempotencyKey != "" {
r.byIdempo[idempoKey{tenant: tenantID, key: *idempotencyKey}] = j
}
r.byID[j.ID] = j
if r.workerStart != nil {
go r.workerStart(j)
}
return j, true, nil
}
func cloneMeta(m map[string]any) map[string]any {
if m == nil {
return map[string]any{}
}
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = v
}
return out
}
func (r *Registry) Get(tenantID, jobID string) (*Job, error) {
r.mu.RLock()
defer r.mu.RUnlock()
j, ok := r.byID[jobID]
if !ok || j.TenantID != tenantID {
return nil, ErrNotFound
}
return j, nil
}
func (r *Registry) List(tenantID, statusFilter, kindFilter, cursor string, limit int) ([]*Job, string, bool) {
if limit <= 0 {
limit = 50
}
r.mu.RLock()
var all []*Job
for _, j := range r.byID {
if j.TenantID != tenantID {
continue
}
if statusFilter != "" && j.Status != statusFilter {
continue
}
if kindFilter != "" && j.Kind != kindFilter {
continue
}
all = append(all, j)
}
r.mu.RUnlock()
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := 0
if cursor != "" {
_ = parseCursor(cursor, &off)
}
end := off + limit
next := ""
hasMore := false
if end > len(all) {
end = len(all)
} else {
hasMore = true
next = formatCursor(end)
}
if off >= len(all) {
return nil, "", false
}
return all[off:end], next, hasMore
}
func parseCursor(s string, off *int) error {
_, err := fmt.Sscanf(s, "%d", off)
return err
}
func formatCursor(off int) string {
return fmt.Sprintf("%d", off)
}
// RequestCancel marks a job for cancellation (best-effort).
func (r *Registry) RequestCancel(tenantID, jobID string) (*Job, error) {
j, err := r.Get(tenantID, jobID)
if err != nil {
return nil, err
}
j.RequestCancel()
return j, nil
}
+98
View File
@@ -0,0 +1,98 @@
package jobs
import (
"evobgp/internal/observability"
"evobgp/internal/store"
)
const (
KindModuleRefresh = "module_refresh"
KindDeployApply = "deploy_apply"
KindRevisionRollback = "revision_rollback"
KindBirdReload = "bird_reload"
)
// Worker executes queued jobs against an in-memory store (stub for full render/deploy pipeline).
type Worker struct {
Store *store.Memory
}
// Process is registered as Registry.workerStart.
func (w *Worker) Process(j *Job) {
defer func() {
observability.RecordJobTerminal(j.Kind, j.statusLocked())
}()
if w == nil || w.Store == nil {
j.MarkRunning()
j.Fail("worker not configured")
return
}
j.MarkRunning()
if j.IsCancelRequested() {
j.MarkCancelled()
return
}
switch j.Kind {
case KindModuleRefresh:
j.Succeed()
case KindDeployApply:
w.runDeployApply(j)
case KindRevisionRollback:
w.runRollback(j)
case KindBirdReload:
j.Succeed()
default:
j.Fail("unknown job kind")
}
}
func (w *Worker) runDeployApply(j *Job) {
rev, _ := j.Meta["revision_id"].(string)
spk, hasSpeaker := j.Meta["speaker_id"].(string)
if rev == "" {
j.Fail("missing revision_id in job meta")
return
}
applyOne := func(speakerID string) error {
if err := w.Store.SetLastAppliedRevision(j.TenantID, speakerID, rev); 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 {
return err
}
return nil
}
if hasSpeaker && spk != "" {
if err := applyOne(spk); err != nil {
j.Fail(err.Error())
return
}
j.Succeed()
return
}
for _, sp := range w.Store.ListSpeakersForTenant(j.TenantID) {
if err := applyOne(sp.ID); err != nil {
j.Fail(err.Error())
return
}
}
j.Succeed()
}
func (w *Worker) runRollback(j *Job) {
src, _ := j.Meta["source_revision_id"].(string)
if src == "" {
j.Fail("missing source_revision_id in job meta")
return
}
newID, err := w.Store.CreateRollbackRevision(j.TenantID, src)
if err != nil {
j.Fail(err.Error())
return
}
j.mergeMeta(map[string]any{"new_revision_id": newID})
j.Succeed()
}
+224
View File
@@ -0,0 +1,224 @@
// Package observability registers Prometheus metrics for the control plane (prefix aggregates, BGP peers, jobs, HTTP).
package observability
import (
"context"
"net/http"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
"github.com/prometheus/client_golang/prometheus/promhttp"
"evobgp/internal/store"
)
const namespace = "evobgp"
var (
metricsMem atomic.Pointer[store.Memory]
registerCollectorOnce sync.Once
)
var (
httpRequests = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "http_requests_total",
Help: "HTTP requests handled by the API mux (excludes /metrics).",
},
[]string{"method", "code"},
)
jobsFinished = promauto.NewCounterVec(
prometheus.CounterOpts{
Namespace: namespace,
Name: "jobs_finished_total",
Help: "Async jobs that reached a terminal state.",
},
[]string{"kind", "status"},
)
birdBGPEstablished = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "bird_bgp_sessions_established",
Help: "BGP sessions in Established state from birdc show protocols (0 if scrape disabled or failed).",
})
birdProtocolsScrapeSuccess = promauto.NewGauge(prometheus.GaugeOpts{
Namespace: namespace,
Name: "bird_protocols_scrape_success",
Help: "1 if the last birdc protocols scrape succeeded, else 0.",
})
buildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
Namespace: namespace,
Name: "build_info",
Help: "Build metadata (value always 1).",
}, []string{"version", "git_sha"})
)
// RecordJobTerminal increments jobs_finished_total for terminal statuses.
func RecordJobTerminal(kind, status string) {
switch status {
case "succeeded", "failed", "cancelled":
jobsFinished.WithLabelValues(kind, status).Inc()
default:
return
}
}
// SetBuildInfo sets evobgp_build_info gauge (idempotent labels).
func SetBuildInfo(version, gitSHA string) {
if version == "" {
version = "unknown"
}
if gitSHA == "" {
gitSHA = "unknown"
}
buildInfo.WithLabelValues(version, gitSHA).Set(1)
}
type memoryStoreCollector struct {
prefixMaxDesc *prometheus.Desc
prefixSumDesc *prometheus.Desc
peersDesc *prometheus.Desc
peerStateDesc *prometheus.Desc
}
func newMemoryStoreCollector() *memoryStoreCollector {
return &memoryStoreCollector{
prefixMaxDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "materialized_prefixes_max"),
"Maximum materialized_prefix_count among all revisions in the store.",
nil, nil,
),
prefixSumDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "materialized_prefixes_sum"),
"Sum of materialized_prefix_count over revisions (development aggregate).",
nil, nil,
),
peersDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "bgp_peers_configured_total"),
"BGP peers configured in the control-plane store.",
nil, nil,
),
peerStateDesc: prometheus.NewDesc(
prometheus.BuildFQName(namespace, "", "bgp_peer_sessions"),
"Configured BGP peers in the store by session_state (intent / last known, not live BIRD).",
[]string{"state"}, nil,
),
}
}
func (c *memoryStoreCollector) Describe(ch chan<- *prometheus.Desc) {
ch <- c.prefixMaxDesc
ch <- c.prefixSumDesc
ch <- c.peersDesc
ch <- c.peerStateDesc
}
func (c *memoryStoreCollector) Collect(ch chan<- prometheus.Metric) {
mem := metricsMem.Load()
if mem == nil {
return
}
maxN, sumN := mem.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() {
if state == "" {
state = "unknown"
}
ch <- prometheus.MustNewConstMetric(c.peerStateDesc, prometheus.GaugeValue, float64(n), state)
}
}
// RegisterStoreMetrics points Prometheus collectors at the given store (last call wins; safe for tests).
func RegisterStoreMetrics(mem *store.Memory) {
if mem == nil {
return
}
metricsMem.Store(mem)
registerCollectorOnce.Do(func() {
prometheus.DefaultRegisterer.MustRegister(newMemoryStoreCollector())
})
}
// SetBirdSessionMetrics updates gauges from an optional birdc scrape.
func SetBirdSessionMetrics(established int, scrapeOK bool) {
birdBGPEstablished.Set(float64(established))
if scrapeOK {
birdProtocolsScrapeSuccess.Set(1)
} else {
birdProtocolsScrapeSuccess.Set(0)
}
}
// MetricsHandler returns the Prometheus scrape handler.
func MetricsHandler() http.Handler {
return promhttp.HandlerFor(prometheus.DefaultGatherer, promhttp.HandlerOpts{})
}
// HTTPMiddleware records method and status code for all wrapped requests.
func HTTPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
httpRequests.WithLabelValues(r.Method, strconv.Itoa(sw.status)).Inc()
})
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// StartBirdProtocolsPoller runs birdc "show protocols" on interval when socket is non-empty.
func StartBirdProtocolsPoller(socket string, birdcPath string, interval time.Duration, showFn func(ctx context.Context, socket, birdcBin string) (string, error), countFn func(output string) int) {
socket = trimSpace(socket)
if socket == "" || interval <= 0 || showFn == nil || countFn == nil {
return
}
scrape := func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
out, err := showFn(ctx, socket, birdcPath)
cancel()
if err != nil {
SetBirdSessionMetrics(0, false)
return
}
SetBirdSessionMetrics(countFn(out), true)
}
go func() {
scrape()
t := time.NewTicker(interval)
defer t.Stop()
for range t.C {
scrape()
}
}()
}
func trimSpace(s string) string {
for len(s) > 0 && (s[0] == ' ' || s[0] == '\t') {
s = s[1:]
}
for len(s) > 0 {
last := s[len(s)-1]
if last != ' ' && last != '\t' {
break
}
s = s[:len(s)-1]
}
return s
}
+16
View File
@@ -0,0 +1,16 @@
// Package platform groups cross-cutting runtime concerns (logging hooks, build/service identity).
// Metrics and HTTP middleware live in internal/observability for now.
package platform
import (
"os"
"strings"
)
// ServiceName returns EVOBGP_SERVICE or defaultName when unset.
func ServiceName(defaultName string) string {
if v := strings.TrimSpace(os.Getenv("EVOBGP_SERVICE")); v != "" {
return v
}
return defaultName
}
+23
View File
@@ -0,0 +1,23 @@
// Package render materializes prefix sets, creates config_revision rows, and builds BIRD text via internal/birdfmt.
package render
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. Reference deployment: worker process after ingest completes.
func Run(ctx context.Context) {
t := time.NewTicker(60 * time.Second)
defer t.Stop()
log.Printf("evobgp-render: started (stub; render jobs produce revisions and artifacts)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-render: stopped")
return
case <-t.C:
}
}
}
+6
View File
@@ -0,0 +1,6 @@
// Package repository will host SQL-backed data access (plan §1: tenant, module, peer, revision, jobs).
//
// The in-memory implementation used by the current API lives in internal/store.
// Migrations: see migrations/ at repository root.
package repository
+25
View File
@@ -0,0 +1,25 @@
// Package scheduler drives module/CDN refresh intervals and enqueues work (broker or job_audit).
// This process entrypoint is a stub until PostgreSQL and NATS/Redis are wired.
package scheduler
import (
"context"
"log"
"time"
)
// Run blocks until ctx is cancelled. In production it connects to DB + broker and schedules refresh events.
func Run(ctx context.Context) {
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)")
for {
select {
case <-ctx.Done():
log.Printf("evobgp-scheduler: stopped")
return
case <-t.C:
// Placeholder: evaluate module cron / refresh_interval and publish jobs.
}
}
}
+13
View File
@@ -0,0 +1,13 @@
// Package signing is the trust boundary for replica bundles: verify Ed25519 signatures separately from TLS (plan §10, §13).
package signing
import (
"crypto/ed25519"
"evobgp/internal/bundle"
)
// VerifyGzippedTar checks the manifest signature and file SHA-256 entries (delegates to internal/bundle).
func VerifyGzippedTar(raw []byte, pub ed25519.PublicKey) (*bundle.VerifiedContents, error) {
return bundle.VerifyGzippedTar(raw, pub)
}
+28
View File
@@ -0,0 +1,28 @@
package signing
import (
"crypto/ed25519"
"crypto/rand"
"testing"
"evobgp/internal/bundle"
)
func TestVerifyGzippedTarDelegates(t *testing.T) {
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
raw, err := bundle.BuildGzippedTar("r1", "s1", map[string]string{"bird.conf": "router id 192.0.2.1;\n"}, priv)
if err != nil {
t.Fatal(err)
}
pub := priv.Public().(ed25519.PublicKey)
v, err := VerifyGzippedTar(raw, pub)
if err != nil {
t.Fatal(err)
}
if v.Manifest.RevisionID != "r1" {
t.Fatalf("revision %q", v.Manifest.RevisionID)
}
}
+494
View File
@@ -0,0 +1,494 @@
package store
import (
"errors"
"fmt"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/google/uuid"
)
var (
ErrNotFound = errors.New("store: not found")
ErrTenantScope = errors.New("store: tenant mismatch")
ErrInvalidInput = errors.New("store: invalid input")
)
// Memory is a development-oriented in-memory backend for control-plane API handlers.
type Memory struct {
mu sync.RWMutex
tenants map[string]*Tenant
modules map[string]*Module
revisions map[string]*Revision
speakers map[string]*Speaker
// speakerID -> latest published revision for evobgp-node pulls
publishedRevision map[string]publishedInfo
peers map[string]*BGPPeer
// DemoIDs valid after SeedDemo()
demoTenantID string
demoModuleCDN string
demoModuleIP string
demoRevisionID string
demoSpeakerID string
}
type publishedInfo struct {
RevisionID string
PublishedAt time.Time
}
type Tenant struct {
ID string
Name string
Slug string
}
type Module struct {
ID string
TenantID string
Type string // AS_PREFIXES, CDN_CIDRS, DOMAINS, IP_RANGES
Name string
Enabled bool
RefreshIntervalSec int // 0 = unset
CronExpr string // optional cron for scheduler (display / future use)
Priority int
DefaultCommunityID *string
DohProfileID *string
}
type Revision struct {
ID string
TenantID string
ModuleID string
ContentHash string
ParentRevisionID *string
CreatedAt time.Time
// MaterializedPrefixCount is the size of the prefix set for this revision (control-plane / render output).
MaterializedPrefixCount int
// PreviewFragments maps logical paths (e.g. bird.conf) to generated text.
PreviewFragments map[string]string
}
// BGPPeer is a minimal stand-in until SQL-backed bgp_peer is wired into the API.
type BGPPeer struct {
ID string
TenantID string
SpeakerID *string
Name string
Neighbor string
SessionState string // e.g. Established, Idle, Connect (intent or cached ops view)
}
type Speaker struct {
ID string
TenantID string
Role string
Endpoint string
LastAppliedRevisionID *string
}
func NewMemory() *Memory {
return &Memory{
tenants: make(map[string]*Tenant),
modules: make(map[string]*Module),
revisions: make(map[string]*Revision),
speakers: make(map[string]*Speaker),
publishedRevision: make(map[string]publishedInfo),
peers: make(map[string]*BGPPeer),
}
}
// SeedDemo installs a minimal tenant, modules, revision, and speaker for local testing.
func (m *Memory) SeedDemo() {
m.mu.Lock()
defer m.mu.Unlock()
tid := uuid.NewString()
m.tenants[tid] = &Tenant{ID: tid, Name: "Demo", Slug: "demo"}
mCDN := uuid.NewString()
m.modules[mCDN] = &Module{
ID: mCDN,
TenantID: tid,
Type: "CDN_CIDRS",
Name: "demo-cdn",
Enabled: true,
RefreshIntervalSec: 3600,
CronExpr: "",
Priority: 10,
}
mIP := uuid.NewString()
m.modules[mIP] = &Module{
ID: mIP,
TenantID: tid,
Type: "IP_RANGES",
Name: "demo-static",
Enabled: true,
RefreshIntervalSec: 0,
CronExpr: "",
Priority: 20,
}
parent := uuid.NewString()
m.revisions[parent] = &Revision{
ID: parent,
TenantID: tid,
ModuleID: "",
ContentHash: "sha256:parent",
ParentRevisionID: nil,
CreatedAt: time.Now().UTC().Add(-time.Hour),
MaterializedPrefixCount: 0,
PreviewFragments: map[string]string{"bird.conf": "# parent revision\n"},
}
rid := uuid.NewString()
m.revisions[rid] = &Revision{
ID: rid,
TenantID: tid,
ModuleID: "",
ContentHash: "sha256:demo-rev-1",
ParentRevisionID: &parent,
CreatedAt: time.Now().UTC(),
MaterializedPrefixCount: 128,
PreviewFragments: map[string]string{
// Valid minimal BIRD 2 skeleton (see internal/birdfmt/testdata/scenarios/minimal/bird.conf).
"bird.conf": `# EvoBGP demo bundle
router id 192.0.2.1;
protocol device {
}
protocol direct {
ipv4;
ipv6;
}
`,
"bird.d/evobgp_demo.conf": "# static demo fragment\n",
},
}
sid := uuid.NewString()
m.speakers[sid] = &Speaker{
ID: sid,
TenantID: tid,
Role: "replica",
Endpoint: "10.0.0.2:179",
LastAppliedRevisionID: nil,
}
m.publishedRevision[sid] = publishedInfo{RevisionID: rid, PublishedAt: time.Now().UTC()}
p1 := uuid.NewString()
m.peers[p1] = &BGPPeer{
ID: p1,
TenantID: tid,
SpeakerID: &sid,
Name: "demo-upstream-4",
Neighbor: "198.51.100.2",
SessionState: "Established",
}
p2 := uuid.NewString()
m.peers[p2] = &BGPPeer{
ID: p2,
TenantID: tid,
SpeakerID: &sid,
Name: "demo-upstream-6",
Neighbor: "2001:db8::2",
SessionState: "Idle",
}
m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID = tid, mCDN, mIP, rid, sid
}
// MaterializedPrefixStats returns max and sum of MaterializedPrefixCount across revisions.
func (m *Memory) MaterializedPrefixStats() (max int, sum int) {
m.mu.RLock()
defer m.mu.RUnlock()
for _, r := range m.revisions {
sum += r.MaterializedPrefixCount
if r.MaterializedPrefixCount > max {
max = r.MaterializedPrefixCount
}
}
return max, sum
}
// PeerCount returns the number of configured BGP peers.
func (m *Memory) PeerCount() int {
m.mu.RLock()
defer m.mu.RUnlock()
return len(m.peers)
}
// PeerSessionCountsByState returns peer counts grouped by SessionState.
func (m *Memory) PeerSessionCountsByState() map[string]int {
m.mu.RLock()
defer m.mu.RUnlock()
out := make(map[string]int)
for _, p := range m.peers {
st := strings.TrimSpace(p.SessionState)
if st == "" {
st = "unknown"
}
out[st]++
}
return out
}
// DemoIDs returns IDs from SeedDemo; empty strings if SeedDemo was not called.
func (m *Memory) DemoIDs() (tenant, moduleCDN, moduleIP, revision, speaker string) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.demoTenantID, m.demoModuleCDN, m.demoModuleIP, m.demoRevisionID, m.demoSpeakerID
}
// ListModules returns modules for a tenant (sorted by priority, then name).
func (m *Memory) ListModules(tenantID string) []*Module {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*Module
for _, mod := range m.modules {
if mod.TenantID == tenantID {
out = append(out, mod)
}
}
sort.Slice(out, func(i, j int) bool {
if out[i].Priority != out[j].Priority {
return out[i].Priority < out[j].Priority
}
return out[i].Name < out[j].Name
})
return out
}
// ListPeers returns BGP peers for a tenant (sorted by name).
func (m *Memory) ListPeers(tenantID string) []*BGPPeer {
m.mu.RLock()
defer m.mu.RUnlock()
var out []*BGPPeer
for _, p := range m.peers {
if p.TenantID == tenantID {
out = append(out, p)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func (m *Memory) GetModule(tenantID, moduleID string) (*Module, error) {
m.mu.RLock()
defer m.mu.RUnlock()
mod, ok := m.modules[moduleID]
if !ok {
return nil, ErrNotFound
}
if mod.TenantID != tenantID {
return nil, ErrTenantScope
}
return mod, nil
}
func (m *Memory) GetRevision(tenantID, revisionID string) (*Revision, error) {
m.mu.RLock()
defer m.mu.RUnlock()
rev, ok := m.revisions[revisionID]
if !ok {
return nil, ErrNotFound
}
if rev.TenantID != tenantID {
return nil, ErrTenantScope
}
return rev, nil
}
func (m *Memory) GetSpeaker(tenantID, speakerID string) (*Speaker, error) {
m.mu.RLock()
defer m.mu.RUnlock()
sp, ok := m.speakers[speakerID]
if !ok {
return nil, ErrNotFound
}
if sp.TenantID != tenantID {
return nil, ErrTenantScope
}
return sp, nil
}
// ListSpeakersForTenant returns speakers for a tenant (IDs sorted) for deploy-all and similar operations.
func (m *Memory) ListSpeakersForTenant(tenantID string) []*Speaker {
m.mu.RLock()
defer m.mu.RUnlock()
var ids []string
for id, sp := range m.speakers {
if sp.TenantID == tenantID {
ids = append(ids, id)
}
}
sort.Strings(ids)
out := make([]*Speaker, 0, len(ids))
for _, id := range ids {
out = append(out, m.speakers[id])
}
return out
}
// GetSpeakerAnyTenant resolves a speaker without tenant check (node API uses speaker id in path).
func (m *Memory) GetSpeakerAnyTenant(speakerID string) (*Speaker, error) {
m.mu.RLock()
defer m.mu.RUnlock()
sp, ok := m.speakers[speakerID]
if !ok {
return nil, ErrNotFound
}
return sp, nil
}
func (m *Memory) LatestPublishedRevision(speakerID string) (revisionID string, publishedAt time.Time, err error) {
m.mu.RLock()
defer m.mu.RUnlock()
info, ok := m.publishedRevision[speakerID]
if !ok {
return "", time.Time{}, ErrNotFound
}
return info.RevisionID, info.PublishedAt, nil
}
// CreateRollbackRevision adds a new revision that reuses content from sourceRevisionID (same hash & preview copy).
func (m *Memory) CreateRollbackRevision(tenantID, sourceRevisionID string) (newID string, err error) {
m.mu.Lock()
defer m.mu.Unlock()
src, ok := m.revisions[sourceRevisionID]
if !ok {
return "", ErrNotFound
}
if src.TenantID != tenantID {
return "", ErrTenantScope
}
newID = uuid.NewString()
frag := make(map[string]string, len(src.PreviewFragments))
for k, v := range src.PreviewFragments {
frag[k] = v
}
parent := sourceRevisionID
m.revisions[newID] = &Revision{
ID: newID,
TenantID: tenantID,
ModuleID: src.ModuleID,
ContentHash: src.ContentHash + ":rollback",
ParentRevisionID: &parent,
CreatedAt: time.Now().UTC(),
MaterializedPrefixCount: src.MaterializedPrefixCount,
PreviewFragments: frag,
}
return newID, nil
}
// SetLastAppliedRevision updates speaker state after deploy job (demo).
func (m *Memory) SetLastAppliedRevision(tenantID, speakerID, revisionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
sp, ok := m.speakers[speakerID]
if !ok {
return ErrNotFound
}
if sp.TenantID != tenantID {
return ErrTenantScope
}
if _, ok := m.revisions[revisionID]; !ok {
return fmt.Errorf("%w: revision", ErrNotFound)
}
sp.LastAppliedRevisionID = &revisionID
return nil
}
// PublishRevisionForSpeaker marks latest bundle pointer for a replica speaker.
func (m *Memory) PublishRevisionForSpeaker(speakerID, revisionID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.speakers[speakerID]; !ok {
return ErrNotFound
}
if _, ok := m.revisions[revisionID]; !ok {
return fmt.Errorf("%w: revision", ErrNotFound)
}
m.publishedRevision[speakerID] = publishedInfo{RevisionID: revisionID, PublishedAt: time.Now().UTC()}
return nil
}
// RevisionDiff returns a trivial JSON-friendly diff between prefix sets (empty for memory demo).
func (m *Memory) RevisionDiff(tenantID, aID, bID string) (map[string]any, error) {
_, err := m.getRevisionLocked(tenantID, aID)
if err != nil {
return nil, err
}
_, err = m.getRevisionLocked(tenantID, bID)
if err != nil {
return nil, err
}
return map[string]any{
"revision_a": aID,
"revision_b": bID,
"prefixes": map[string]any{
"added": []string{},
"removed": []string{},
"unchanged_count": 0,
},
}, nil
}
func (m *Memory) getRevisionLocked(tenantID, revisionID string) (*Revision, error) {
rev, ok := m.revisions[revisionID]
if !ok {
return nil, ErrNotFound
}
if rev.TenantID != tenantID {
return nil, ErrTenantScope
}
return rev, nil
}
// ListRevisions returns recent revisions for tenant (newest first), cursor is opaque offset string.
func (m *Memory) ListRevisions(tenantID, moduleID string, cursor string, limit int) (items []*Revision, nextCursor string, hasMore bool) {
if limit <= 0 {
limit = 50
}
m.mu.RLock()
defer m.mu.RUnlock()
var all []*Revision
for _, r := range m.revisions {
if r.TenantID != tenantID {
continue
}
if moduleID != "" && r.ModuleID != moduleID {
continue
}
all = append(all, r)
}
sort.Slice(all, func(i, j int) bool {
return all[i].CreatedAt.After(all[j].CreatedAt)
})
off := 0
if cursor != "" {
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
off = n
}
}
end := off + limit
if end > len(all) {
end = len(all)
}
page := all[off:end]
if end < len(all) {
nextCursor = fmt.Sprintf("%d", end)
hasMore = true
}
return page, nextCursor, hasMore
}
+13
View File
@@ -0,0 +1,13 @@
package migrations
import "embed"
// Postgres contains versioned SQL for the primary PostgreSQL path (plan §11).
//
//go:embed postgres/*.sql
var Postgres embed.FS
// SQLite contains the parallel schema for optional microVPS_sqlite (plan §2, §11).
//
//go:embed sqlite/*.sql
var SQLite embed.FS
+15
View File
@@ -0,0 +1,15 @@
DROP TABLE IF EXISTS module_cdn_fetch_log;
DROP TABLE IF EXISTS global_settings;
DROP TABLE IF EXISTS job_audit;
DROP TABLE IF EXISTS revision_materialized_prefix;
DROP TABLE IF EXISTS bgp_peer;
DROP TABLE IF EXISTS bgp_speaker;
DROP TABLE IF EXISTS config_revision;
DROP TABLE IF EXISTS module_ip_range_entry;
DROP TABLE IF EXISTS module_as_entry;
DROP TABLE IF EXISTS module_domain_entry;
DROP TABLE IF EXISTS module_cdn_source;
DROP TABLE IF EXISTS module;
DROP TABLE IF EXISTS bgp_community;
DROP TABLE IF EXISTS doh_profile;
DROP TABLE IF EXISTS tenant;
+220
View File
@@ -0,0 +1,220 @@
-- EvoBGP initial schema (PostgreSQL). See plan §5.
-- UUIDs: application may use UUID v7/ULID as text-compatible UUID.
CREATE TABLE tenant (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tenant_slug_chk CHECK (length(trim(slug)) > 0),
CONSTRAINT tenant_slug_uniq UNIQUE (slug)
);
CREATE TABLE doh_profile (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
timeout_ms INTEGER,
secret_ref TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT doh_profile_url_chk CHECK (length(trim(url)) > 0)
);
CREATE INDEX idx_doh_profile_tenant ON doh_profile (tenant_id);
CREATE TABLE bgp_community (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
value_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bgp_community_kind_chk CHECK (length(trim(kind)) > 0)
);
CREATE INDEX idx_bgp_community_tenant ON bgp_community (tenant_id);
CREATE TABLE module (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
priority INTEGER NOT NULL DEFAULT 0,
doh_profile_id UUID REFERENCES doh_profile (id) ON DELETE SET NULL,
refresh_interval_sec INTEGER,
cron_expr TEXT,
default_community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT module_type_chk CHECK (type IN (
'AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS', 'IP_RANGES'
)),
CONSTRAINT module_name_chk CHECK (length(trim(name)) > 0)
);
CREATE INDEX idx_module_tenant ON module (tenant_id);
CREATE INDEX idx_module_tenant_type ON module (tenant_id, type);
CREATE INDEX idx_module_tenant_enabled ON module (tenant_id, enabled) WHERE deleted_at IS NULL;
CREATE TABLE module_cdn_source (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
source_kind TEXT NOT NULL,
url TEXT NOT NULL,
etag TEXT,
refresh_interval_sec INTEGER,
community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT module_cdn_source_url_chk CHECK (length(trim(url)) > 0)
);
CREATE INDEX idx_module_cdn_source_module ON module_cdn_source (module_id);
CREATE TABLE module_domain_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
fqdn TEXT NOT NULL,
community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL,
resolve_meta JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT module_domain_fqdn_chk CHECK (length(trim(fqdn)) > 0)
);
CREATE INDEX idx_module_domain_entry_module ON module_domain_entry (module_id);
CREATE TABLE module_as_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
asn BIGINT,
prefix CIDR,
community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT module_as_entry_asn_or_prefix_chk CHECK (asn IS NOT NULL OR prefix IS NOT NULL)
);
CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id);
CREATE TABLE module_ip_range_entry (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
prefix CIDR NOT NULL,
community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_module_ip_range_entry_module ON module_ip_range_entry (module_id);
-- Full config snapshot for a tenant (optionally tied to one module for filtering in API).
CREATE TABLE config_revision (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
module_id UUID REFERENCES module (id) ON DELETE SET NULL,
content_hash TEXT NOT NULL,
parent_revision_id UUID REFERENCES config_revision (id) ON DELETE SET NULL,
artifact_ref TEXT,
meta_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT config_revision_hash_chk CHECK (length(trim(content_hash)) > 0)
);
CREATE INDEX idx_config_revision_tenant ON config_revision (tenant_id, created_at DESC);
CREATE INDEX idx_config_revision_module ON config_revision (module_id) WHERE module_id IS NOT NULL;
CREATE TABLE bgp_speaker (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
role TEXT NOT NULL,
endpoint TEXT,
last_applied_revision_id UUID REFERENCES config_revision (id) ON DELETE SET NULL,
meta_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT bgp_speaker_role_chk CHECK (role IN ('master', 'replica'))
);
CREATE INDEX idx_bgp_speaker_tenant ON bgp_speaker (tenant_id);
CREATE TABLE bgp_peer (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
bgp_speaker_id UUID REFERENCES bgp_speaker (id) ON DELETE SET NULL,
neighbor INET NOT NULL,
remote_asn BIGINT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
policies_json JSONB NOT NULL DEFAULT '{}',
meta_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_bgp_peer_tenant ON bgp_peer (tenant_id);
CREATE INDEX idx_bgp_peer_speaker ON bgp_peer (bgp_speaker_id);
CREATE TABLE revision_materialized_prefix (
id BIGSERIAL PRIMARY KEY,
revision_id UUID NOT NULL REFERENCES config_revision (id) ON DELETE CASCADE,
prefix CIDR NOT NULL,
community_id UUID REFERENCES bgp_community (id) ON DELETE SET NULL,
source TEXT NOT NULL DEFAULT '',
meta_json JSONB NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_rev_mat_prefix_revision ON revision_materialized_prefix (revision_id);
CREATE INDEX idx_rev_mat_prefix_value ON revision_materialized_prefix (prefix);
CREATE TABLE job_audit (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
status TEXT NOT NULL,
idempotency_key TEXT,
module_id UUID REFERENCES module (id) ON DELETE SET NULL,
error_message TEXT,
progress_pct SMALLINT,
meta_json JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
started_at TIMESTAMPTZ,
finished_at TIMESTAMPTZ,
CONSTRAINT job_audit_status_chk CHECK (status IN (
'queued', 'running', 'succeeded', 'failed', 'cancelled'
)),
CONSTRAINT job_audit_kind_chk CHECK (length(trim(kind)) > 0)
);
CREATE INDEX idx_job_audit_tenant_created ON job_audit (tenant_id, created_at DESC);
CREATE INDEX idx_job_audit_tenant_status ON job_audit (tenant_id, status);
CREATE UNIQUE INDEX idx_job_audit_idempotency
ON job_audit (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE global_settings (
tenant_id UUID NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
key TEXT NOT NULL,
value_json JSONB NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (tenant_id, key),
CONSTRAINT global_settings_key_chk CHECK (length(trim(key)) > 0)
);
-- Optional CDN fetch audit (plan §5.4).
CREATE TABLE module_cdn_fetch_log (
id BIGSERIAL PRIMARY KEY,
module_id UUID NOT NULL REFERENCES module (id) ON DELETE CASCADE,
source_id UUID REFERENCES module_cdn_source (id) ON DELETE SET NULL,
http_status INTEGER,
bytes BIGINT,
error TEXT,
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_module_cdn_fetch_log_module ON module_cdn_fetch_log (module_id, fetched_at DESC);
+17
View File
@@ -0,0 +1,17 @@
PRAGMA foreign_keys = ON;
DROP TABLE IF EXISTS module_cdn_fetch_log;
DROP TABLE IF EXISTS global_settings;
DROP TABLE IF EXISTS job_audit;
DROP TABLE IF EXISTS revision_materialized_prefix;
DROP TABLE IF EXISTS bgp_peer;
DROP TABLE IF EXISTS bgp_speaker;
DROP TABLE IF EXISTS config_revision;
DROP TABLE IF EXISTS module_ip_range_entry;
DROP TABLE IF EXISTS module_as_entry;
DROP TABLE IF EXISTS module_domain_entry;
DROP TABLE IF EXISTS module_cdn_source;
DROP TABLE IF EXISTS module;
DROP TABLE IF EXISTS bgp_community;
DROP TABLE IF EXISTS doh_profile;
DROP TABLE IF EXISTS tenant;
+214
View File
@@ -0,0 +1,214 @@
-- EvoBGP initial schema (SQLite, optional microVPS_sqlite / single-container).
-- UUIDs: supply from application (ULID/UUIDv7). JSON columns stored as TEXT.
PRAGMA foreign_keys = ON;
CREATE TABLE tenant (
id TEXT PRIMARY KEY NOT NULL,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (length(trim(slug)) > 0)
);
CREATE TABLE doh_profile (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
url TEXT NOT NULL,
timeout_ms INTEGER,
secret_ref TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (length(trim(url)) > 0)
);
CREATE INDEX idx_doh_profile_tenant ON doh_profile (tenant_id);
CREATE TABLE bgp_community (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
name TEXT NOT NULL DEFAULT '',
kind TEXT NOT NULL,
value_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (length(trim(kind)) > 0)
);
CREATE INDEX idx_bgp_community_tenant ON bgp_community (tenant_id);
CREATE TABLE module (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 0,
doh_profile_id TEXT REFERENCES doh_profile (id) ON DELETE SET NULL,
refresh_interval_sec INTEGER,
cron_expr TEXT,
default_community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
deleted_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (type IN ('AS_PREFIXES', 'CDN_CIDRS', 'DOMAINS', 'IP_RANGES')),
CHECK (length(trim(name)) > 0)
);
CREATE INDEX idx_module_tenant ON module (tenant_id);
CREATE INDEX idx_module_tenant_type ON module (tenant_id, type);
CREATE INDEX idx_module_tenant_enabled ON module (tenant_id, enabled) WHERE deleted_at IS NULL;
CREATE TABLE module_cdn_source (
id TEXT PRIMARY KEY NOT NULL,
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
source_kind TEXT NOT NULL,
url TEXT NOT NULL,
etag TEXT,
refresh_interval_sec INTEGER,
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (length(trim(url)) > 0)
);
CREATE INDEX idx_module_cdn_source_module ON module_cdn_source (module_id);
CREATE TABLE module_domain_entry (
id TEXT PRIMARY KEY NOT NULL,
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
fqdn TEXT NOT NULL,
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
resolve_meta TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (length(trim(fqdn)) > 0)
);
CREATE INDEX idx_module_domain_entry_module ON module_domain_entry (module_id);
CREATE TABLE module_as_entry (
id TEXT PRIMARY KEY NOT NULL,
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
asn INTEGER,
prefix TEXT,
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (asn IS NOT NULL OR prefix IS NOT NULL)
);
CREATE INDEX idx_module_as_entry_module ON module_as_entry (module_id);
CREATE TABLE module_ip_range_entry (
id TEXT PRIMARY KEY NOT NULL,
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
prefix TEXT NOT NULL,
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_module_ip_range_entry_module ON module_ip_range_entry (module_id);
CREATE TABLE config_revision (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
module_id TEXT REFERENCES module (id) ON DELETE SET NULL,
content_hash TEXT NOT NULL,
parent_revision_id TEXT REFERENCES config_revision (id) ON DELETE SET NULL,
artifact_ref TEXT,
meta_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (length(trim(content_hash)) > 0)
);
CREATE INDEX idx_config_revision_tenant ON config_revision (tenant_id, created_at DESC);
CREATE INDEX idx_config_revision_module ON config_revision (module_id) WHERE module_id IS NOT NULL;
CREATE TABLE bgp_speaker (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
role TEXT NOT NULL,
endpoint TEXT,
last_applied_revision_id TEXT REFERENCES config_revision (id) ON DELETE SET NULL,
meta_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (role IN ('master', 'replica'))
);
CREATE INDEX idx_bgp_speaker_tenant ON bgp_speaker (tenant_id);
CREATE TABLE bgp_peer (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
bgp_speaker_id TEXT REFERENCES bgp_speaker (id) ON DELETE SET NULL,
neighbor TEXT NOT NULL,
remote_asn INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
policies_json TEXT NOT NULL DEFAULT '{}',
meta_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_bgp_peer_tenant ON bgp_peer (tenant_id);
CREATE INDEX idx_bgp_peer_speaker ON bgp_peer (bgp_speaker_id);
CREATE TABLE revision_materialized_prefix (
id INTEGER PRIMARY KEY AUTOINCREMENT,
revision_id TEXT NOT NULL REFERENCES config_revision (id) ON DELETE CASCADE,
prefix TEXT NOT NULL,
community_id TEXT REFERENCES bgp_community (id) ON DELETE SET NULL,
source TEXT NOT NULL DEFAULT '',
meta_json TEXT NOT NULL DEFAULT '{}'
);
CREATE INDEX idx_rev_mat_prefix_revision ON revision_materialized_prefix (revision_id);
CREATE INDEX idx_rev_mat_prefix_value ON revision_materialized_prefix (prefix);
CREATE TABLE job_audit (
id TEXT PRIMARY KEY NOT NULL,
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
kind TEXT NOT NULL,
status TEXT NOT NULL,
idempotency_key TEXT,
module_id TEXT REFERENCES module (id) ON DELETE SET NULL,
error_message TEXT,
progress_pct INTEGER,
meta_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
started_at TEXT,
finished_at TEXT,
CHECK (status IN ('queued', 'running', 'succeeded', 'failed', 'cancelled')),
CHECK (length(trim(kind)) > 0)
);
CREATE INDEX idx_job_audit_tenant_created ON job_audit (tenant_id, created_at DESC);
CREATE INDEX idx_job_audit_tenant_status ON job_audit (tenant_id, status);
CREATE UNIQUE INDEX idx_job_audit_idempotency ON job_audit (tenant_id, idempotency_key)
WHERE idempotency_key IS NOT NULL;
CREATE TABLE global_settings (
tenant_id TEXT NOT NULL REFERENCES tenant (id) ON DELETE CASCADE,
key TEXT NOT NULL,
value_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (tenant_id, key),
CHECK (length(trim(key)) > 0)
);
CREATE TABLE module_cdn_fetch_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
module_id TEXT NOT NULL REFERENCES module (id) ON DELETE CASCADE,
source_id TEXT REFERENCES module_cdn_source (id) ON DELETE SET NULL,
http_status INTEGER,
bytes INTEGER,
error TEXT,
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
);
CREATE INDEX idx_module_cdn_fetch_log_module ON module_cdn_fetch_log (module_id, fetched_at DESC);
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+19
View File
@@ -0,0 +1,19 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": [
"prettier-plugin-svelte",
"prettier-plugin-tailwindcss"
],
"tailwindStylesheet": "./src/routes/layout.css",
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+42
View File
@@ -0,0 +1,42 @@
# sv
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
## Creating a project
If you're seeing this, you've probably already done this step. Congrats!
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx [email protected] create --template minimal --types ts --add tailwindcss="plugins:none" sveltekit-adapter="adapter:static" prettier --no-download-check --install npm web
```
## Developing
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
To create a production version of your app:
```sh
npm run build
```
You can preview the production build with `npm run preview`.
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
+2402
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
{
"name": "web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@lucide/svelte": "^1.7.0",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { page } from '$app/state';
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import { cn } from '$lib/utils.js';
import { Separator } from '$lib/components/ui/separator/index.js';
import Activity from '@lucide/svelte/icons/activity';
import Boxes from '@lucide/svelte/icons/boxes';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
import Network from '@lucide/svelte/icons/network';
import Settings from '@lucide/svelte/icons/settings';
import Gauge from '@lucide/svelte/icons/gauge';
const nav = [
{ href: '/', label: 'Обзор', icon: LayoutDashboard },
{ href: '/modules', label: 'Модули', icon: Boxes },
{ href: '/revisions', label: 'Ревизии', icon: Activity },
{ href: '/peers', label: 'Пиры и спикеры', icon: Network },
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
{ href: '/settings', label: 'Настройки', icon: Settings }
] as const;
let { children } = $props();
</script>
<div class="bg-background flex min-h-screen">
<aside
class="border-border bg-card/30 flex w-56 shrink-0 flex-col border-r"
aria-label="Основная навигация"
>
<div class="p-4">
<a href={resolve('/')} class="text-foreground font-semibold tracking-tight">EvoBGP</a>
<p class="text-muted-foreground mt-0.5 text-xs">Control plane</p>
</div>
<Separator />
<nav class="flex flex-1 flex-col gap-0.5 p-2">
{#each nav as item (item.href)}
{@const Icon = item.icon}
<a
href={resolve(item.href)}
class={cn(
buttonVariants({
variant: page.url.pathname === item.href ? 'secondary' : 'ghost',
size: 'sm'
}),
'w-full justify-start gap-2 no-underline'
)}
>
<Icon class="size-4" />
{item.label}
</a>
{/each}
</nav>
</aside>
<main class="min-w-0 flex-1 p-6">
{@render children?.()}
</main>
</div>
+47
View File
@@ -0,0 +1,47 @@
import { browser } from '$app/environment';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
export type Problem = {
title?: string;
status?: number;
detail?: string;
};
function mergeHeaders(init?: RequestInit): Headers {
const h = new Headers(init?.headers);
if (!h.has('Accept')) {
h.set('Accept', 'application/json');
}
if (browser) {
const t = localStorage.getItem(TOKEN_STORAGE_KEY);
if (t && !h.has('Authorization')) {
h.set('Authorization', `Bearer ${t}`);
}
}
return h;
}
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
if (!browser) {
throw new Error('API is only available in the browser');
}
return fetch(path, { ...init, headers: mergeHeaders(init) });
}
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init);
const text = await res.text();
if (!res.ok) {
let detail = text;
try {
const j = JSON.parse(text) as Problem;
if (j?.detail) detail = j.detail;
} catch {
/* plain text */
}
throw new Error(detail || `HTTP ${res.status}`);
}
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
+77
View File
@@ -0,0 +1,77 @@
export type ModuleRow = {
id: string;
type: string;
name: string;
enabled: boolean;
priority: number;
refresh_interval_sec: number;
cron_expr: string;
default_community_id: string | null;
doh_profile_id: string | null;
};
export type ModulesResponse = {
items: ModuleRow[];
next_cursor: string | null;
has_more: boolean;
};
export type RevisionRow = {
id: string;
content_hash: string;
created_at: string;
parent_revision_id: string | null;
materialized_prefix_count: number;
module_id: string | null;
};
export type RevisionsResponse = {
items: RevisionRow[];
next_cursor: string | null;
has_more: boolean;
};
export type PeerRow = {
id: string;
name: string;
neighbor: string;
session_state: string;
bgp_speaker_id: string | null;
};
export type PeersResponse = {
items: PeerRow[];
next_cursor: string | null;
has_more: boolean;
};
export type SpeakerRow = {
id: string;
role: string;
endpoint: string;
last_applied_revision_id: string | null;
};
export type SpeakersResponse = {
items: SpeakerRow[];
next_cursor: string | null;
has_more: boolean;
};
export type JobRow = {
job_id: string;
kind: string;
status: string;
idempotency_key?: string | null;
created_at?: string;
started_at?: string | null;
finished_at?: string | null;
error?: string | null;
meta?: Record<string, unknown>;
};
export type JobsResponse = {
items: JobRow[];
next_cursor: string | null;
has_more: boolean;
};
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,38 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const badgeVariants = tv({
base: 'focus:ring-ring inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:ring-2 focus:ring-offset-2 focus:outline-none',
variants: {
variant: {
default: 'bg-primary text-primary-foreground border-transparent shadow hover:bg-primary/80',
secondary: 'bg-secondary text-secondary-foreground border-transparent hover:bg-secondary/80',
destructive:
'bg-destructive text-destructive-foreground border-transparent shadow hover:bg-destructive/80',
outline: 'text-foreground'
}
},
defaultVariants: {
variant: 'default'
}
});
export type BadgeVariant = VariantProps<typeof badgeVariants>['variant'];
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
variant = 'default',
children,
...rest
}: HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant;
children?: import('svelte').Snippet;
} = $props();
</script>
<span class={cn(badgeVariants({ variant }), className)} {...rest}>{@render children?.()}</span>
+2
View File
@@ -0,0 +1,2 @@
import Root, { badgeVariants, type BadgeVariant } from './badge.svelte';
export { Root, Root as Badge, badgeVariants, type BadgeVariant };
@@ -0,0 +1,54 @@
<script lang="ts" module>
import { type VariantProps, tv } from 'tailwind-variants';
export const buttonVariants = tv({
base: 'focus-visible:border-ring focus-visible:ring-ring/50 inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium outline-none transition-all focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*="size-"])]:size-4',
variants: {
variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
outline:
'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:hover:bg-input/50',
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
link: 'text-primary underline-offset-4 hover:underline'
},
size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
icon: 'size-9'
}
},
defaultVariants: {
variant: 'default',
size: 'default'
}
});
export type ButtonVariant = VariantProps<typeof buttonVariants>['variant'];
export type ButtonSize = VariantProps<typeof buttonVariants>['size'];
</script>
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLButtonAttributes } from 'svelte/elements';
let {
class: className,
variant = 'default',
size = 'default',
type = 'button',
children,
...rest
}: HTMLButtonAttributes & {
variant?: ButtonVariant;
size?: ButtonSize;
children?: import('svelte').Snippet;
} = $props();
</script>
<button class={cn(buttonVariants({ variant, size }), className)} {type} {...rest}
>{@render children?.()}</button
>
@@ -0,0 +1,8 @@
import Root, { buttonVariants, type ButtonSize, type ButtonVariant } from './button.svelte';
export {
Root,
Root as Button,
buttonVariants,
type ButtonSize,
type ButtonVariant
};
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div class={cn('p-6 pt-0', className)} {...rest}>{@render children?.()}</div>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLParagraphElement> & { children?: import('svelte').Snippet } = $props();
</script>
<p class={cn('text-muted-foreground text-sm', className)} {...rest}>{@render children?.()}</p>
@@ -0,0 +1,12 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div class={cn('flex flex-col gap-1.5 p-6', className)} {...rest}>{@render children?.()}</div>
@@ -0,0 +1,14 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLHeadingElement> & { children?: import('svelte').Snippet } = $props();
</script>
<h3 class={cn('leading-none font-semibold tracking-tight', className)} {...rest}>
{@render children?.()}
</h3>
@@ -0,0 +1,17 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
children,
...rest
}: HTMLAttributes<HTMLDivElement> & { children?: import('svelte').Snippet } = $props();
</script>
<div
class={cn('bg-card text-card-foreground rounded-xl border shadow-sm', className)}
{...rest}
>
{@render children?.()}
</div>
+14
View File
@@ -0,0 +1,14 @@
import Root from './card.svelte';
import Header from './card-header.svelte';
import Title from './card-title.svelte';
import Description from './card-description.svelte';
import Content from './card-content.svelte';
export {
Root,
Root as Card,
Header as CardHeader,
Title as CardTitle,
Description as CardDescription,
Content as CardContent
};

Some files were not shown because too many files have changed in this diff Show More