Compare commits
4
Commits
aff27e8f7b
...
64516c316d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
64516c316d | ||
|
|
ea65451160 | ||
|
|
250cdea35d | ||
|
|
1dd713514c |
No files matched your search
@@ -234,7 +234,7 @@ npx @redocly/cli lint docs/openapi.yaml
|
||||
# birdfmt: go test ./internal/birdfmt/... -count=1
|
||||
```
|
||||
|
||||
**Рекомендуется (не внедрено):** CI job `web`; `.golangci.yml`; pre-commit gofmt/prettier.
|
||||
**Рекомендуется (частично внедрено):** CI job `web` (Gitea); `scripts/lint-httpapi.sh` в job `go`; `.golangci.yml` (локально); pre-commit gofmt/prettier.
|
||||
|
||||
**Только code review:** слои SQL; роли; idempotency; OpenAPI bodies; secrets в compose.
|
||||
|
||||
|
||||
@@ -121,6 +121,26 @@ jobs:
|
||||
- name: Lint OpenAPI (Redocly)
|
||||
run: npx --yes @redocly/cli@1 lint docs/openapi.yaml
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
web:
|
||||
needs: [changes]
|
||||
if: needs.changes.outputs.web == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
- name: npm ci, check, lint
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
cd web
|
||||
npm ci
|
||||
npm run check
|
||||
npm run lint
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
go:
|
||||
needs: [changes]
|
||||
@@ -135,6 +155,8 @@ jobs:
|
||||
cache-dependency-path: go.sum
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Lint httpapi (ERR-01 / ARCH-01)
|
||||
run: sh scripts/lint-httpapi.sh
|
||||
- name: Test
|
||||
run: go test ./... -race -count=1
|
||||
- name: Build all commands
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
run:
|
||||
timeout: 5m
|
||||
|
||||
linters:
|
||||
enable:
|
||||
- gofmt
|
||||
- govet
|
||||
- errcheck
|
||||
|
||||
issues:
|
||||
exclude-use-default: false
|
||||
max-issues-per-linter: 50
|
||||
max-same-issues: 20
|
||||
|
||||
linters-settings:
|
||||
gofmt:
|
||||
simplify: true
|
||||
+3
-228
@@ -1,240 +1,15 @@
|
||||
package 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"
|
||||
"evobgp/internal/nodecli"
|
||||
)
|
||||
|
||||
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()
|
||||
nodecli.Usage(os.Stderr)
|
||||
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
|
||||
os.Exit(nodecli.Run(os.Args[1:]))
|
||||
}
|
||||
@@ -41,6 +41,9 @@ func main() {
|
||||
|
||||
apiBase := strings.TrimSpace(os.Getenv("EVOBGP_CONTROL_PLANE_URL"))
|
||||
apiTok := strings.TrimSpace(os.Getenv("EVOBGP_SCHEDULER_BEARER"))
|
||||
if os.Getenv("EVOBGP_SCHEDULER_STANDALONE") == "1" && apiBase == "" {
|
||||
log.Fatal("evobgp-scheduler: EVOBGP_SCHEDULER_STANDALONE=1 requires EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER (split deploy must not use in-process jobs.Registry)")
|
||||
}
|
||||
deps := &scheduler.Deps{
|
||||
Store: st,
|
||||
HTTP: &http.Client{Timeout: 45 * time.Second},
|
||||
|
||||
@@ -126,6 +126,8 @@ services:
|
||||
condition: service_started
|
||||
environment:
|
||||
<<: *env-ref
|
||||
# Split deploy: scheduler must enqueue jobs via API, not in-process Registry (ARCH-04).
|
||||
EVOBGP_SCHEDULER_STANDALONE: "1"
|
||||
EVOBGP_CONTROL_PLANE_URL: http://evobgp-api:8080
|
||||
EVOBGP_SCHEDULER_BEARER: dev
|
||||
logging: *default-logging
|
||||
|
||||
@@ -15,6 +15,11 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// NewCDNHTTPClient returns the shared HTTP client for CDN and preview fetches (PERF-02 / ERR-03).
|
||||
func NewCDNHTTPClient() *http.Client {
|
||||
return &http.Client{Timeout: 45 * time.Second}
|
||||
}
|
||||
|
||||
// BootstrapWorkers opens the same store.Backend and jobs.Registry as New (without HTTP or bundle keys).
|
||||
// Used by standalone worker binaries (scheduler, ingest, …) that share PostgreSQL with the API.
|
||||
func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.Registry, *pgxpool.Pool, error) {
|
||||
@@ -44,7 +49,7 @@ func BootstrapWorkers(ctx context.Context, opts Options) (store.Backend, *jobs.R
|
||||
backend = mem
|
||||
}
|
||||
|
||||
cdnHTTP := &http.Client{Timeout: 45 * time.Second}
|
||||
cdnHTTP := NewCDNHTTPClient()
|
||||
wk := &jobs.Worker{Store: backend, HTTPClient: cdnHTTP}
|
||||
reg := jobs.NewRegistry(wk.Process)
|
||||
wk.Registry = reg
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func parseListLimit(r *http.Request) int {
|
||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||
if limit <= 0 {
|
||||
return 50
|
||||
}
|
||||
if limit > 500 {
|
||||
return 500
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
func strPtrOrNull(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -2,9 +2,15 @@ package httpapi
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const (
|
||||
internalErrorDetail = "an internal error occurred"
|
||||
badGatewayDetail = "upstream request failed"
|
||||
)
|
||||
|
||||
// Problem is RFC 9457 application/problem+json.
|
||||
type Problem struct {
|
||||
Type string `json:"type,omitempty"`
|
||||
@@ -34,3 +40,19 @@ func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
func writeNoContent(w http.ResponseWriter) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// writeInternalError logs err server-side and returns a generic 500 problem (ERR-01).
|
||||
func writeInternalError(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: %s: %v", operation, err)
|
||||
}
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", internalErrorDetail)
|
||||
}
|
||||
|
||||
// writeBadGateway logs err server-side and returns a generic 502 problem (ERR-01).
|
||||
func writeBadGateway(w http.ResponseWriter, operation string, err error) {
|
||||
if err != nil {
|
||||
log.Printf("httpapi: %s: %v", operation, err)
|
||||
}
|
||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", badGatewayDetail)
|
||||
}
|
||||
+38
-323
@@ -19,6 +19,7 @@ import (
|
||||
"evobgp/internal/jobs"
|
||||
"evobgp/internal/observability"
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/reports"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
@@ -83,7 +84,7 @@ func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||
defer cancel()
|
||||
if s.pgPool != nil {
|
||||
if err := s.pgPool.Ping(ctx); err != nil {
|
||||
checks["postgres"] = err.Error()
|
||||
checks["postgres"] = "unavailable"
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"status": "not_ready", "checks": checks})
|
||||
return
|
||||
}
|
||||
@@ -189,19 +190,23 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
||||
enabledFilter = &v
|
||||
}
|
||||
|
||||
mods := s.store.ListModules(a.TenantID)
|
||||
items := make([]map[string]any, 0, len(mods))
|
||||
for _, mod := range mods {
|
||||
filtered := make([]*store.Module, 0)
|
||||
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||
if typeFilter != "" && mod.Type != typeFilter {
|
||||
continue
|
||||
}
|
||||
if enabledFilter != nil && mod.Enabled != *enabledFilter {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, mod)
|
||||
}
|
||||
page, next, more := store.PaginateOffset(filtered, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, mod := range page {
|
||||
items = append(items, moduleJSON(mod))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": nil, "has_more": false,
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -214,91 +219,17 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
|
||||
mods := s.store.ListModules(a.TenantID)
|
||||
moduleItems := make([]map[string]any, 0, len(mods))
|
||||
domains := make([]map[string]any, 0)
|
||||
asns := make([]map[string]any, 0)
|
||||
ipRanges := make([]map[string]any, 0)
|
||||
|
||||
for _, mod := range mods {
|
||||
switch mod.Type {
|
||||
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
||||
moduleItems = append(moduleItems, moduleJSON(mod))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
switch mod.Type {
|
||||
case "DOMAINS":
|
||||
list, err := s.store.ListDomainEntries(a.TenantID, mod.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, x := range list {
|
||||
domains = append(domains, map[string]any{
|
||||
"module_id": mod.ID,
|
||||
"entry": domainEntryJSON(x),
|
||||
})
|
||||
}
|
||||
case "AS_PREFIXES":
|
||||
list, err := s.store.ListASEntries(a.TenantID, mod.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, x := range list {
|
||||
asns = append(asns, map[string]any{
|
||||
"module_id": mod.ID,
|
||||
"entry": asEntryJSON(x),
|
||||
})
|
||||
}
|
||||
case "IP_RANGES":
|
||||
list, err := s.store.ListIPRangeEntries(a.TenantID, mod.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
for _, x := range list {
|
||||
ipRanges = append(ipRanges, map[string]any{
|
||||
"module_id": mod.ID,
|
||||
"entry": ipRangeJSON(x),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comms, err := s.store.ListCommunities(a.TenantID)
|
||||
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
communityItems := make([]map[string]any, 0, len(comms))
|
||||
for _, c := range comms {
|
||||
communityItems = append(communityItems, map[string]any{
|
||||
"id": c.ID,
|
||||
"community": c.Community,
|
||||
"title": c.Title,
|
||||
})
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"modules": map[string]any{
|
||||
"items": moduleItems,
|
||||
},
|
||||
"domains": map[string]any{
|
||||
"items": domains,
|
||||
},
|
||||
"asns": map[string]any{
|
||||
"items": asns,
|
||||
},
|
||||
"ip_ranges": map[string]any{
|
||||
"items": ipRanges,
|
||||
},
|
||||
"communities": map[string]any{
|
||||
"items": communityItems,
|
||||
},
|
||||
"modules": map[string]any{"items": cat.Modules},
|
||||
"domains": map[string]any{"items": cat.Domains},
|
||||
"asns": map[string]any{"items": cat.ASNs},
|
||||
"ip_ranges": map[string]any{"items": cat.IPRanges},
|
||||
"communities": map[string]any{"items": cat.Communities},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -317,7 +248,7 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, moduleJSON(mod))
|
||||
@@ -332,10 +263,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireAtLeast(w, a, "viewer") {
|
||||
return
|
||||
}
|
||||
peers := s.store.ListPeers(a.TenantID)
|
||||
allPeers := s.store.ListPeers(a.TenantID)
|
||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||
liveStates := s.liveBGPProtocolStates(r.Context())
|
||||
items := make([]map[string]any, 0, len(peers))
|
||||
for _, p := range peers {
|
||||
items := make([]map[string]any, 0, len(page))
|
||||
for _, p := range page {
|
||||
row := peerJSON(p)
|
||||
if st, ok := liveStates[peerProtocolNameForID(p.ID)]; ok && strings.TrimSpace(st) != "" {
|
||||
row["session_state"] = strings.TrimSpace(st)
|
||||
@@ -343,7 +275,7 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
||||
items = append(items, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"items": items, "next_cursor": nil, "has_more": false,
|
||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -451,7 +383,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||
return
|
||||
}
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
idem := r.Header.Get("Idempotency-Key")
|
||||
@@ -463,7 +395,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
@@ -512,7 +444,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
"trigger": "api",
|
||||
})
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
@@ -570,13 +502,6 @@ func revisionJSON(rev *store.Revision) map[string]any {
|
||||
return m
|
||||
}
|
||||
|
||||
func strPtrOrNull(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// enqueueModuleRefreshIfEnabled queues module_refresh when the module exists and is enabled (best-effort, no HTTP error).
|
||||
func (s *Server) enqueueModuleRefreshIfEnabled(tenantID, moduleID, trigger string) {
|
||||
if s.jobs == nil {
|
||||
@@ -672,225 +597,15 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
|
||||
writeProblem(w, http.StatusNotFound, "Not Found", "revision not found")
|
||||
return
|
||||
}
|
||||
|
||||
moduleType := ""
|
||||
moduleName := ""
|
||||
if strings.TrimSpace(rev.ModuleID) != "" {
|
||||
if mod, modErr := s.store.GetModule(a.TenantID, rev.ModuleID); modErr == nil && mod != nil {
|
||||
moduleType = strings.TrimSpace(mod.Type)
|
||||
moduleName = strings.TrimSpace(mod.Name)
|
||||
}
|
||||
logOut, err := reports.BuildRevisionDiagnosticLog(s.store, a.TenantID, rev)
|
||||
if err != nil {
|
||||
writeInternalError(w, "revision diagnostic log", err)
|
||||
return
|
||||
}
|
||||
|
||||
communityLabels := map[string]string{}
|
||||
if communities, listErr := s.store.ListCommunities(a.TenantID); listErr == nil {
|
||||
for _, c := range communities {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(c.Title)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(c.Community)
|
||||
}
|
||||
if label == "" {
|
||||
label = c.ID
|
||||
}
|
||||
communityLabels[c.ID] = label
|
||||
}
|
||||
}
|
||||
|
||||
type rawRow struct {
|
||||
Prefix string
|
||||
Source string
|
||||
Kind string
|
||||
SourceName string
|
||||
SourceDetail string
|
||||
CommunityID string
|
||||
CommunityLabel string
|
||||
}
|
||||
type summary struct {
|
||||
Kind string
|
||||
Source string
|
||||
SourceDetail string
|
||||
CommunityID string
|
||||
CommunityLabel string
|
||||
Count int
|
||||
Sample []string
|
||||
}
|
||||
|
||||
cdnSourceURLByID := map[string]string{}
|
||||
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
||||
if sources, srcErr := s.store.ListCDNSources(a.TenantID, rev.ModuleID); srcErr == nil {
|
||||
for _, src := range sources {
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(src.ID)
|
||||
url := strings.TrimSpace(src.URL)
|
||||
if id != "" && url != "" {
|
||||
cdnSourceURLByID[id] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
||||
byGroup := map[string]*summary{}
|
||||
cursor := ""
|
||||
for {
|
||||
page, next, more := s.store.ListRevisionPrefixes(a.TenantID, revID, cursor, 2000)
|
||||
for _, p := range page {
|
||||
kind, sourceName := classifyRevisionSource(p.Source)
|
||||
sourceDetail := sourceName
|
||||
if kind == "cdn" {
|
||||
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
||||
sourceDetail = url
|
||||
}
|
||||
}
|
||||
communityID := "none"
|
||||
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
||||
communityID = strings.TrimSpace(*p.CommunityID)
|
||||
}
|
||||
communityLabel := "без community"
|
||||
if communityID != "none" {
|
||||
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
||||
communityLabel = lbl
|
||||
} else {
|
||||
communityLabel = communityID
|
||||
}
|
||||
}
|
||||
rows = append(rows, rawRow{
|
||||
Prefix: p.Prefix,
|
||||
Source: p.Source,
|
||||
Kind: kind,
|
||||
SourceName: sourceName,
|
||||
SourceDetail: sourceDetail,
|
||||
CommunityID: communityID,
|
||||
CommunityLabel: communityLabel,
|
||||
})
|
||||
groupKey := kind + "|" + sourceName + "|" + communityID
|
||||
g, ok := byGroup[groupKey]
|
||||
if !ok {
|
||||
g = &summary{
|
||||
Kind: kind,
|
||||
Source: sourceName,
|
||||
SourceDetail: sourceDetail,
|
||||
CommunityID: communityID,
|
||||
CommunityLabel: communityLabel,
|
||||
Sample: make([]string, 0, 5),
|
||||
}
|
||||
byGroup[groupKey] = g
|
||||
}
|
||||
g.Count++
|
||||
if len(g.Sample) < 5 {
|
||||
g.Sample = append(g.Sample, p.Prefix)
|
||||
}
|
||||
}
|
||||
if !more || strings.TrimSpace(next) == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
|
||||
summaryKeys := make([]string, 0, len(byGroup))
|
||||
for k := range byGroup {
|
||||
summaryKeys = append(summaryKeys, k)
|
||||
}
|
||||
sort.Strings(summaryKeys)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Kind != rows[j].Kind {
|
||||
return rows[i].Kind < rows[j].Kind
|
||||
}
|
||||
if rows[i].SourceName != rows[j].SourceName {
|
||||
return rows[i].SourceName < rows[j].SourceName
|
||||
}
|
||||
if rows[i].CommunityID != rows[j].CommunityID {
|
||||
return rows[i].CommunityID < rows[j].CommunityID
|
||||
}
|
||||
return rows[i].Prefix < rows[j].Prefix
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("EvoBGP revision diagnostic log\n")
|
||||
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
||||
b.WriteString("tenant_id=" + a.TenantID + "\n")
|
||||
b.WriteString("revision_id=" + rev.ID + "\n")
|
||||
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
||||
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
||||
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
||||
if rev.ModuleID != "" {
|
||||
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
||||
} else {
|
||||
b.WriteString("module_id=\n")
|
||||
}
|
||||
b.WriteString("module_type=" + moduleType + "\n")
|
||||
b.WriteString("module_name=" + moduleName + "\n")
|
||||
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
||||
|
||||
b.WriteString("## Aggregation by source and community\n")
|
||||
for _, k := range summaryKeys {
|
||||
g := byGroup[k]
|
||||
b.WriteString("- kind=" + g.Kind +
|
||||
" source=" + g.Source +
|
||||
" source_detail=" + g.SourceDetail +
|
||||
" community_id=" + g.CommunityID +
|
||||
" community_label=" + g.CommunityLabel +
|
||||
" count=" + strconv.Itoa(g.Count))
|
||||
if len(g.Sample) > 0 {
|
||||
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
b.WriteString("\n## Raw rows\n")
|
||||
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
||||
for _, row := range rows {
|
||||
b.WriteString(row.Prefix)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(row.Kind)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(row.Source)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(row.CommunityID)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
filename := "revision-" + shortRevisionID(rev.ID) + "-diagnostic.log"
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+filename+`"`)
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+logOut.Filename+`"`)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte(b.String()))
|
||||
}
|
||||
|
||||
func classifyRevisionSource(src string) (kind, sourceName string) {
|
||||
switch {
|
||||
case strings.HasPrefix(src, "as:"):
|
||||
return "asn", strings.TrimPrefix(src, "as:")
|
||||
case strings.HasPrefix(src, "domain:"):
|
||||
return "domain", strings.TrimPrefix(src, "domain:")
|
||||
case strings.HasPrefix(src, "cdn:"):
|
||||
return "cdn", strings.TrimPrefix(src, "cdn:")
|
||||
case src == "ip_range":
|
||||
return "ip_range", "manual_ranges"
|
||||
default:
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "unknown", "unknown"
|
||||
}
|
||||
return "source", strings.TrimSpace(src)
|
||||
}
|
||||
}
|
||||
|
||||
func shortRevisionID(id string) string {
|
||||
s := strings.TrimSpace(id)
|
||||
if len(s) <= 8 {
|
||||
return s
|
||||
}
|
||||
return s[:8]
|
||||
_, _ = w.Write(logOut.Body)
|
||||
}
|
||||
|
||||
func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -916,7 +631,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||
return
|
||||
}
|
||||
if !s.requireAtLeast(w, a, "editor") {
|
||||
if !s.requireAtLeast(w, a, "operator") {
|
||||
return
|
||||
}
|
||||
revID := r.PathValue("revision_id")
|
||||
@@ -933,7 +648,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
||||
"source_revision_id": revID,
|
||||
})
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
@@ -980,7 +695,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
||||
"strategy": body.Strategy,
|
||||
})
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
@@ -1031,7 +746,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
||||
"speaker_id": spkID,
|
||||
})
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
@@ -1056,7 +771,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindBirdReload, idemPtr, nil, nil)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||
@@ -1191,7 +906,7 @@ func (s *Server) handleNodeBundle(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
tgz, err := bundle.BuildGzippedTar(rid, sid, rev.PreviewFragments, s.bundlePriv)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/gzip")
|
||||
@@ -1241,7 +956,7 @@ func (s *Server) handleNodeEnroll(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
patch := &store.SpeakerPatch{MetaJSON: &meta}
|
||||
if _, err := s.store.UpdateSpeaker(a.TenantID, sid, patch); err != nil {
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "internal", err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
|
||||
+32
-152
@@ -3,6 +3,7 @@ package httpapi
|
||||
import (
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -10,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/importer"
|
||||
"evobgp/internal/pipeline"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
@@ -178,7 +180,7 @@ func writeStoreErr(w http.ResponseWriter, err error) {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", err.Error())
|
||||
return
|
||||
}
|
||||
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||
writeInternalError(w, "store", err)
|
||||
}
|
||||
|
||||
func (s *Server) handleListCDNSources(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -251,20 +253,20 @@ func (s *Server) handlePreviewCDNSource(w http.ResponseWriter, r *http.Request)
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "invalid url")
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := s.cdnHTTP.Do(req)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
||||
writeBadGateway(w, "cdn preview fetch", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", fmt.Sprintf("upstream status: %s", resp.Status))
|
||||
writeBadGateway(w, "cdn preview fetch", fmt.Errorf("upstream status: %s", resp.Status))
|
||||
return
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadGateway, "Bad Gateway", err.Error())
|
||||
writeBadGateway(w, "cdn preview read body", err)
|
||||
return
|
||||
}
|
||||
pfxs, err := pipeline.ExtractCIDRs(string(raw), body.SourceKind, body.PrefixPath)
|
||||
@@ -689,163 +691,41 @@ func (s *Server) handleImportModuleEntriesCSV(w http.ResponseWriter, r *http.Req
|
||||
return
|
||||
}
|
||||
moduleID := r.PathValue("module_id")
|
||||
mod, err := s.store.GetModule(a.TenantID, moduleID)
|
||||
res, err := importer.ImportModuleEntriesCSV(s.store, a.TenantID, moduleID, r.Body)
|
||||
if err != nil {
|
||||
if errors.Is(err, store.ErrInvalidInput) {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: invalid csv") {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: line") {
|
||||
detail := strings.TrimPrefix(err.Error(), "importer: ")
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", detail)
|
||||
return
|
||||
}
|
||||
if strings.Contains(err.Error(), "importer: csv import/export") {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||
return
|
||||
}
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
cr := csv.NewReader(io.LimitReader(r.Body, 8<<20))
|
||||
cr.TrimLeadingSpace = true
|
||||
cr.FieldsPerRecord = -1
|
||||
rows, err := cr.ReadAll()
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid csv")
|
||||
return
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv is empty")
|
||||
return
|
||||
}
|
||||
|
||||
communities, err := s.store.ListCommunities(a.TenantID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
communityIDByID := make(map[string]string, len(communities))
|
||||
communityIDByValue := make(map[string]string, len(communities))
|
||||
for _, c := range communities {
|
||||
communityIDByID[c.ID] = c.ID
|
||||
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
||||
}
|
||||
|
||||
resolveCommunity := func(raw string, required bool) (*string, error) {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
if required {
|
||||
return nil, fmt.Errorf("community is required")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if id, ok := communityIDByID[v]; ok {
|
||||
return &id, nil
|
||||
}
|
||||
if id, ok := communityIDByValue[v]; ok {
|
||||
return &id, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown community %q", v)
|
||||
}
|
||||
|
||||
start := 0
|
||||
if len(rows[0]) >= 2 {
|
||||
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
||||
switch key {
|
||||
case "asn", "domain", "iprange":
|
||||
start = 1
|
||||
}
|
||||
}
|
||||
|
||||
imported := 0
|
||||
switch mod.Type {
|
||||
case "AS_PREFIXES":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
||||
return
|
||||
}
|
||||
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
||||
if err != nil || asn <= 0 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: invalid asn", i+1))
|
||||
return
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], false)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
||||
return
|
||||
}
|
||||
_, err = s.store.CreateASEntry(a.TenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
imported++
|
||||
}
|
||||
if imported > 0 {
|
||||
if res.Imported > 0 {
|
||||
switch res.ModuleType {
|
||||
case "AS_PREFIXES":
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "as_entry_import_csv")
|
||||
}
|
||||
case "DOMAINS":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
||||
return
|
||||
}
|
||||
fqdn := strings.TrimSpace(rec[0])
|
||||
if fqdn == "" {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: domain is required", i+1))
|
||||
return
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], false)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
||||
return
|
||||
}
|
||||
_, err = s.store.CreateDomainEntry(a.TenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
imported++
|
||||
}
|
||||
if imported > 0 {
|
||||
case "DOMAINS":
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "domain_entry_import_csv")
|
||||
}
|
||||
case "IP_RANGES":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: expected 2 columns", i+1))
|
||||
return
|
||||
}
|
||||
prefix := strings.TrimSpace(rec[0])
|
||||
if prefix == "" {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: ipRange is required", i+1))
|
||||
return
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], true)
|
||||
if err != nil {
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", fmt.Sprintf("line %d: %v", i+1, err))
|
||||
return
|
||||
}
|
||||
_, err = s.store.CreateIPRangeEntry(a.TenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid})
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
imported++
|
||||
}
|
||||
if imported > 0 {
|
||||
case "IP_RANGES":
|
||||
s.enqueueModuleRefreshIfEnabled(a.TenantID, moduleID, "ip_range_import_csv")
|
||||
}
|
||||
default:
|
||||
writeProblem(w, http.StatusUnprocessableEntity, "Unprocessable Entity", "csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"imported": imported,
|
||||
"module_type": mod.Type,
|
||||
"imported": res.Imported,
|
||||
"module_type": res.ModuleType,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ type Server struct {
|
||||
apiKeys []apiKeyRecord
|
||||
insecureDev bool
|
||||
corsOrigins []string
|
||||
cdnHTTP *http.Client
|
||||
mux *http.ServeMux
|
||||
}
|
||||
|
||||
@@ -67,6 +68,7 @@ func New(opts Options) (*Server, error) {
|
||||
apiKeys: parseAPIKeysSpec(opts.APIKeys),
|
||||
insecureDev: opts.InsecureDev && opts.SeedDemo,
|
||||
corsOrigins: parseCORSOrigins(opts.CORSAllowedOrigins),
|
||||
cdnHTTP: NewCDNHTTPClient(),
|
||||
}
|
||||
s.mux = http.NewServeMux()
|
||||
s.registerRoutes()
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
}
|
||||
defer srv.Close()
|
||||
tenant, modCDN, modIP, rev, speaker := srv.Store().DemoIDs()
|
||||
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator")
|
||||
srv.apiKeys = parseAPIKeysSpec("nodekey|" + tenant + "|node,opkey|" + tenant + "|operator,edkey|" + tenant + "|editor")
|
||||
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
defer ts.Close()
|
||||
@@ -247,6 +247,20 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rollback forbidden for editor", func(t *testing.T) {
|
||||
req, _ := http.NewRequest(http.MethodPost, base+"/v1/revisions/"+rev+"/rollback", nil)
|
||||
req.Header.Set("Authorization", "Bearer edkey")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("status %d want 403: %s", resp.StatusCode, b)
|
||||
}
|
||||
})
|
||||
|
||||
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")
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package importer
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// Result of importing module entries from CSV.
|
||||
type Result struct {
|
||||
Imported int
|
||||
ModuleType string
|
||||
}
|
||||
|
||||
// ImportModuleEntriesCSV parses and creates entries for AS_PREFIXES, DOMAINS, or IP_RANGES modules.
|
||||
func ImportModuleEntriesCSV(st store.Backend, tenantID, moduleID string, body io.Reader) (*Result, error) {
|
||||
mod, err := st.GetModule(tenantID, moduleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cr := csv.NewReader(io.LimitReader(body, 8<<20))
|
||||
cr.TrimLeadingSpace = true
|
||||
cr.FieldsPerRecord = -1
|
||||
rows, err := cr.ReadAll()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("importer: invalid csv: %w", err)
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil, fmt.Errorf("importer: %w", store.ErrInvalidInput)
|
||||
}
|
||||
|
||||
communities, err := st.ListCommunities(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
communityIDByID := make(map[string]string, len(communities))
|
||||
communityIDByValue := make(map[string]string, len(communities))
|
||||
for _, c := range communities {
|
||||
communityIDByID[c.ID] = c.ID
|
||||
communityIDByValue[strings.TrimSpace(c.Community)] = c.ID
|
||||
}
|
||||
|
||||
resolveCommunity := func(raw string, required bool) (*string, error) {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
if required {
|
||||
return nil, fmt.Errorf("community is required")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
if id, ok := communityIDByID[v]; ok {
|
||||
return &id, nil
|
||||
}
|
||||
if id, ok := communityIDByValue[v]; ok {
|
||||
return &id, nil
|
||||
}
|
||||
return nil, fmt.Errorf("unknown community %q", v)
|
||||
}
|
||||
|
||||
start := 0
|
||||
if len(rows[0]) >= 2 {
|
||||
key := strings.ToLower(strings.TrimSpace(rows[0][0]))
|
||||
switch key {
|
||||
case "asn", "domain", "iprange":
|
||||
start = 1
|
||||
}
|
||||
}
|
||||
|
||||
imported := 0
|
||||
switch mod.Type {
|
||||
case "AS_PREFIXES":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||
}
|
||||
asn, err := strconv.ParseInt(strings.TrimSpace(rec[0]), 10, 64)
|
||||
if err != nil || asn <= 0 {
|
||||
return nil, fmt.Errorf("importer: line %d: invalid asn", i+1)
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := st.CreateASEntry(tenantID, moduleID, &store.ASEntry{ASN: asn, CommunityID: cid}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
case "DOMAINS":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||
}
|
||||
fqdn := strings.TrimSpace(rec[0])
|
||||
if fqdn == "" {
|
||||
return nil, fmt.Errorf("importer: line %d: domain is required", i+1)
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := st.CreateDomainEntry(tenantID, moduleID, &store.DomainEntry{FQDN: fqdn, CommunityID: cid}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
case "IP_RANGES":
|
||||
for i := start; i < len(rows); i++ {
|
||||
rec := rows[i]
|
||||
if len(rec) == 0 || (strings.TrimSpace(rec[0]) == "" && (len(rec) < 2 || strings.TrimSpace(rec[1]) == "")) {
|
||||
continue
|
||||
}
|
||||
if len(rec) < 2 {
|
||||
return nil, fmt.Errorf("importer: line %d: expected 2 columns", i+1)
|
||||
}
|
||||
prefix := strings.TrimSpace(rec[0])
|
||||
if prefix == "" {
|
||||
return nil, fmt.Errorf("importer: line %d: ipRange is required", i+1)
|
||||
}
|
||||
cid, err := resolveCommunity(rec[1], true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("importer: line %d: %w", i+1, err)
|
||||
}
|
||||
if _, err := st.CreateIPRangeEntry(tenantID, moduleID, &store.IPRangeEntry{Prefix: prefix, CommunityID: cid}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
imported++
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("importer: csv import/export is supported only for AS_PREFIXES, DOMAINS, IP_RANGES")
|
||||
}
|
||||
|
||||
return &Result{Imported: imported, ModuleType: mod.Type}, nil
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package nodecli
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// CmdPullBundle downloads a signed bundle from the control plane API.
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
// CmdVerifyBundle checks bundle signature and manifest.
|
||||
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
|
||||
}
|
||||
|
||||
// CmdApplyBundle verifies, extracts, parse-checks, and configures BIRD from a bundle.
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package nodecli implements evobgp-node subcommands (pull/verify/apply bundle).
|
||||
package nodecli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// Run executes a subcommand from args (without program name). Returns exit code.
|
||||
func Run(args []string) int {
|
||||
if len(args) < 1 {
|
||||
Usage(os.Stderr)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "pull-bundle":
|
||||
return CmdPullBundle(args[1:])
|
||||
case "verify-bundle":
|
||||
return CmdVerifyBundle(args[1:])
|
||||
case "apply-bundle":
|
||||
return CmdApplyBundle(args[1:])
|
||||
default:
|
||||
Usage(os.Stderr)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// Usage prints CLI help to w.
|
||||
func Usage(w interface{ Write([]byte) (int, error) }) {
|
||||
fmt.Fprintf(w, `Usage:
|
||||
evobgp-node pull-bundle -base-url URL -token TOKEN -speaker-id ID [-revision-id ID] [-o path]
|
||||
evobgp-node verify-bundle -f bundle.tar.gz (-pubkey-base64 B64 | -pubkey-hex HEX)
|
||||
evobgp-node 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.
|
||||
`)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Package pipeline ingests module inputs (CDN, ASN, domains, IP ranges), materializes
|
||||
// prefixes, and creates BIRD preview revisions via store.
|
||||
package pipeline
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
// moduleIngestInputHash fingerprints module config and child entries so snapshots invalidate on CRUD.
|
||||
func moduleIngestInputHash(st store.Backend, tenantID string, mod *store.Module) (string, error) {
|
||||
if st == nil || mod == nil {
|
||||
return "", fmt.Errorf("module hash: missing store or module")
|
||||
return "", fmt.Errorf("pipeline: module hash: missing store or module")
|
||||
}
|
||||
h := sha256.New()
|
||||
_, _ = fmt.Fprintf(h, "type=%s\n", strings.TrimSpace(mod.Type))
|
||||
|
||||
@@ -52,7 +52,7 @@ func RefreshModuleIngest(ctx context.Context, st store.Backend, hc *http.Client,
|
||||
return err
|
||||
}
|
||||
if !mod.Enabled {
|
||||
return fmt.Errorf("module disabled")
|
||||
return fmt.Errorf("pipeline: module disabled")
|
||||
}
|
||||
|
||||
var prior []store.PrefixRow
|
||||
@@ -185,7 +185,7 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
|
||||
}
|
||||
return collectDomainPrefixRows(ctx, hc, mod, profiles, policy, entries)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown module type %q", mod.Type)
|
||||
return nil, fmt.Errorf("pipeline: unknown module type %q", mod.Type)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -76,5 +76,5 @@ func PickTenantRefreshTriggerModule(st store.Backend, tenantID string, moduleIDs
|
||||
return mod.ID, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no enabled module for tenant refresh")
|
||||
return "", fmt.Errorf("pipeline: no enabled module for tenant refresh")
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package reports
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// RouterListsCatalog is the aggregated router-lists payload for GET /router-lists/catalog.
|
||||
type RouterListsCatalog struct {
|
||||
Modules []map[string]any
|
||||
Domains []map[string]any
|
||||
ASNs []map[string]any
|
||||
IPRanges []map[string]any
|
||||
Communities []map[string]any
|
||||
}
|
||||
|
||||
// BuildRouterListsCatalog loads modules and entries for the tenant catalog endpoint.
|
||||
func BuildRouterListsCatalog(st store.Backend, tenantID string) (*RouterListsCatalog, error) {
|
||||
mods := st.ListModules(tenantID)
|
||||
out := &RouterListsCatalog{
|
||||
Modules: make([]map[string]any, 0),
|
||||
Domains: make([]map[string]any, 0),
|
||||
ASNs: make([]map[string]any, 0),
|
||||
IPRanges: make([]map[string]any, 0),
|
||||
Communities: make([]map[string]any, 0),
|
||||
}
|
||||
|
||||
for _, mod := range mods {
|
||||
switch mod.Type {
|
||||
case "DOMAINS", "AS_PREFIXES", "IP_RANGES":
|
||||
out.Modules = append(out.Modules, moduleMap(mod))
|
||||
default:
|
||||
continue
|
||||
}
|
||||
|
||||
switch mod.Type {
|
||||
case "DOMAINS":
|
||||
list, err := st.ListDomainEntries(tenantID, mod.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range list {
|
||||
out.Domains = append(out.Domains, map[string]any{
|
||||
"module_id": mod.ID,
|
||||
"entry": domainEntryMap(x),
|
||||
})
|
||||
}
|
||||
case "AS_PREFIXES":
|
||||
list, err := st.ListASEntries(tenantID, mod.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range list {
|
||||
out.ASNs = append(out.ASNs, map[string]any{
|
||||
"module_id": mod.ID,
|
||||
"entry": asEntryMap(x),
|
||||
})
|
||||
}
|
||||
case "IP_RANGES":
|
||||
list, err := st.ListIPRangeEntries(tenantID, mod.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range list {
|
||||
out.IPRanges = append(out.IPRanges, map[string]any{
|
||||
"module_id": mod.ID,
|
||||
"entry": ipRangeMap(x),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
comms, err := st.ListCommunities(tenantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range comms {
|
||||
out.Communities = append(out.Communities, map[string]any{
|
||||
"id": c.ID,
|
||||
"community": c.Community,
|
||||
"title": c.Title,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func moduleMap(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.LastRefreshedAt != nil {
|
||||
m["last_refreshed_at"] = mod.LastRefreshedAt.UTC().Format(time.RFC3339Nano)
|
||||
} else {
|
||||
m["last_refreshed_at"] = nil
|
||||
}
|
||||
if mod.DefaultCommunityID != nil {
|
||||
m["default_community_id"] = *mod.DefaultCommunityID
|
||||
} else {
|
||||
m["default_community_id"] = nil
|
||||
}
|
||||
ids := mod.EffectiveDohProfileIDs()
|
||||
if len(ids) > 0 {
|
||||
m["doh_profile_ids"] = ids
|
||||
} else {
|
||||
m["doh_profile_ids"] = []string{}
|
||||
}
|
||||
m["doh_resolver_policy"] = store.NormalizeDohResolverPolicy(mod.DohResolverPolicy)
|
||||
if mod.DohProfileID != nil {
|
||||
m["doh_profile_id"] = *mod.DohProfileID
|
||||
} else {
|
||||
m["doh_profile_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func domainEntryMap(x *store.DomainEntry) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "fqdn": x.FQDN}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func asEntryMap(x *store.ASEntry) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "asn": x.ASN}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func ipRangeMap(x *store.IPRangeEntry) map[string]any {
|
||||
m := map[string]any{"id": x.ID, "prefix": x.Prefix}
|
||||
if x.CommunityID != nil {
|
||||
m["community_id"] = *x.CommunityID
|
||||
} else {
|
||||
m["community_id"] = nil
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package reports
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// RevisionDiagnosticLog is plain-text diagnostic output for a revision.
|
||||
type RevisionDiagnosticLog struct {
|
||||
Body []byte
|
||||
Filename string
|
||||
}
|
||||
|
||||
// BuildRevisionDiagnosticLog aggregates revision prefixes by source and community.
|
||||
func BuildRevisionDiagnosticLog(st store.Backend, tenantID string, rev *store.Revision) (*RevisionDiagnosticLog, error) {
|
||||
moduleType := ""
|
||||
moduleName := ""
|
||||
if strings.TrimSpace(rev.ModuleID) != "" {
|
||||
if mod, modErr := st.GetModule(tenantID, rev.ModuleID); modErr == nil && mod != nil {
|
||||
moduleType = strings.TrimSpace(mod.Type)
|
||||
moduleName = strings.TrimSpace(mod.Name)
|
||||
}
|
||||
}
|
||||
|
||||
communityLabels := map[string]string{}
|
||||
if communities, listErr := st.ListCommunities(tenantID); listErr == nil {
|
||||
for _, c := range communities {
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
label := strings.TrimSpace(c.Title)
|
||||
if label == "" {
|
||||
label = strings.TrimSpace(c.Community)
|
||||
}
|
||||
if label == "" {
|
||||
label = c.ID
|
||||
}
|
||||
communityLabels[c.ID] = label
|
||||
}
|
||||
}
|
||||
|
||||
type rawRow struct {
|
||||
Prefix string
|
||||
Source string
|
||||
Kind string
|
||||
SourceName string
|
||||
SourceDetail string
|
||||
CommunityID string
|
||||
CommunityLabel string
|
||||
}
|
||||
type summary struct {
|
||||
Kind string
|
||||
Source string
|
||||
SourceDetail string
|
||||
CommunityID string
|
||||
CommunityLabel string
|
||||
Count int
|
||||
Sample []string
|
||||
}
|
||||
|
||||
cdnSourceURLByID := map[string]string{}
|
||||
if moduleType == "CDN_CIDRS" && strings.TrimSpace(rev.ModuleID) != "" {
|
||||
if sources, srcErr := st.ListCDNSources(tenantID, rev.ModuleID); srcErr == nil {
|
||||
for _, src := range sources {
|
||||
if src == nil {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(src.ID)
|
||||
url := strings.TrimSpace(src.URL)
|
||||
if id != "" && url != "" {
|
||||
cdnSourceURLByID[id] = url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
rows := make([]rawRow, 0, rev.MaterializedPrefixCount)
|
||||
byGroup := map[string]*summary{}
|
||||
cursor := ""
|
||||
for {
|
||||
page, next, more := st.ListRevisionPrefixes(tenantID, rev.ID, cursor, 2000)
|
||||
for _, p := range page {
|
||||
kind, sourceName := classifyRevisionSource(p.Source)
|
||||
sourceDetail := sourceName
|
||||
if kind == "cdn" {
|
||||
if url, ok := cdnSourceURLByID[sourceName]; ok && strings.TrimSpace(url) != "" {
|
||||
sourceDetail = url
|
||||
}
|
||||
}
|
||||
communityID := "none"
|
||||
if p.CommunityID != nil && strings.TrimSpace(*p.CommunityID) != "" {
|
||||
communityID = strings.TrimSpace(*p.CommunityID)
|
||||
}
|
||||
communityLabel := "без community"
|
||||
if communityID != "none" {
|
||||
if lbl, ok := communityLabels[communityID]; ok && strings.TrimSpace(lbl) != "" {
|
||||
communityLabel = lbl
|
||||
} else {
|
||||
communityLabel = communityID
|
||||
}
|
||||
}
|
||||
rows = append(rows, rawRow{
|
||||
Prefix: p.Prefix,
|
||||
Source: p.Source,
|
||||
Kind: kind,
|
||||
SourceName: sourceName,
|
||||
SourceDetail: sourceDetail,
|
||||
CommunityID: communityID,
|
||||
CommunityLabel: communityLabel,
|
||||
})
|
||||
groupKey := kind + "|" + sourceName + "|" + communityID
|
||||
g, ok := byGroup[groupKey]
|
||||
if !ok {
|
||||
g = &summary{
|
||||
Kind: kind,
|
||||
Source: sourceName,
|
||||
SourceDetail: sourceDetail,
|
||||
CommunityID: communityID,
|
||||
CommunityLabel: communityLabel,
|
||||
Sample: make([]string, 0, 5),
|
||||
}
|
||||
byGroup[groupKey] = g
|
||||
}
|
||||
g.Count++
|
||||
if len(g.Sample) < 5 {
|
||||
g.Sample = append(g.Sample, p.Prefix)
|
||||
}
|
||||
}
|
||||
if !more || strings.TrimSpace(next) == "" {
|
||||
break
|
||||
}
|
||||
cursor = next
|
||||
}
|
||||
|
||||
summaryKeys := make([]string, 0, len(byGroup))
|
||||
for k := range byGroup {
|
||||
summaryKeys = append(summaryKeys, k)
|
||||
}
|
||||
sort.Strings(summaryKeys)
|
||||
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if rows[i].Kind != rows[j].Kind {
|
||||
return rows[i].Kind < rows[j].Kind
|
||||
}
|
||||
if rows[i].SourceName != rows[j].SourceName {
|
||||
return rows[i].SourceName < rows[j].SourceName
|
||||
}
|
||||
if rows[i].CommunityID != rows[j].CommunityID {
|
||||
return rows[i].CommunityID < rows[j].CommunityID
|
||||
}
|
||||
return rows[i].Prefix < rows[j].Prefix
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("EvoBGP revision diagnostic log\n")
|
||||
b.WriteString("generated_at=" + time.Now().UTC().Format(time.RFC3339Nano) + "\n")
|
||||
b.WriteString("tenant_id=" + tenantID + "\n")
|
||||
b.WriteString("revision_id=" + rev.ID + "\n")
|
||||
b.WriteString("revision_created_at=" + rev.CreatedAt.UTC().Format(time.RFC3339Nano) + "\n")
|
||||
b.WriteString("content_hash=" + rev.ContentHash + "\n")
|
||||
b.WriteString("materialized_prefix_count=" + strconv.Itoa(rev.MaterializedPrefixCount) + "\n")
|
||||
if rev.ModuleID != "" {
|
||||
b.WriteString("module_id=" + rev.ModuleID + "\n")
|
||||
} else {
|
||||
b.WriteString("module_id=\n")
|
||||
}
|
||||
b.WriteString("module_type=" + moduleType + "\n")
|
||||
b.WriteString("module_name=" + moduleName + "\n")
|
||||
b.WriteString("fetched_rows=" + strconv.Itoa(len(rows)) + "\n\n")
|
||||
|
||||
b.WriteString("## Aggregation by source and community\n")
|
||||
for _, k := range summaryKeys {
|
||||
g := byGroup[k]
|
||||
b.WriteString("- kind=" + g.Kind +
|
||||
" source=" + g.Source +
|
||||
" source_detail=" + g.SourceDetail +
|
||||
" community_id=" + g.CommunityID +
|
||||
" community_label=" + g.CommunityLabel +
|
||||
" count=" + strconv.Itoa(g.Count))
|
||||
if len(g.Sample) > 0 {
|
||||
b.WriteString(" sample=" + strings.Join(g.Sample, ","))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
b.WriteString("\n## Raw rows\n")
|
||||
b.WriteString("prefix\tkind\tsource\tsource_detail\tcommunity_id\tcommunity_label\n")
|
||||
for _, row := range rows {
|
||||
b.WriteString(row.Prefix)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(row.Kind)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(row.Source)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(strings.ReplaceAll(row.SourceDetail, "\t", " "))
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(row.CommunityID)
|
||||
b.WriteByte('\t')
|
||||
b.WriteString(strings.ReplaceAll(row.CommunityLabel, "\t", " "))
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("revision-%s-diagnostic.log", shortRevisionID(rev.ID))
|
||||
return &RevisionDiagnosticLog{Body: []byte(b.String()), Filename: filename}, nil
|
||||
}
|
||||
|
||||
func classifyRevisionSource(src string) (kind, sourceName string) {
|
||||
switch {
|
||||
case strings.HasPrefix(src, "as:"):
|
||||
return "asn", strings.TrimPrefix(src, "as:")
|
||||
case strings.HasPrefix(src, "domain:"):
|
||||
return "domain", strings.TrimPrefix(src, "domain:")
|
||||
case strings.HasPrefix(src, "cdn:"):
|
||||
return "cdn", strings.TrimPrefix(src, "cdn:")
|
||||
case src == "ip_range":
|
||||
return "ip_range", "manual_ranges"
|
||||
default:
|
||||
if strings.TrimSpace(src) == "" {
|
||||
return "unknown", "unknown"
|
||||
}
|
||||
return "source", strings.TrimSpace(src)
|
||||
}
|
||||
}
|
||||
|
||||
func shortRevisionID(id string) string {
|
||||
s := strings.TrimSpace(id)
|
||||
if len(s) <= 8 {
|
||||
return s
|
||||
}
|
||||
return s[:8]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Backend interface {
|
||||
// ListTenantIDs returns distinct tenant identifiers (for background workers).
|
||||
ListTenantIDs() ([]string, error)
|
||||
|
||||
// ListModules returns all modules for a tenant (control plane may paginate in httpapi).
|
||||
ListModules(tenantID string) []*Module
|
||||
GetModule(tenantID, moduleID string) (*Module, error)
|
||||
CreateModule(tenantID string, in *Module) (*Module, error)
|
||||
@@ -53,6 +54,7 @@ type Backend interface {
|
||||
UpdateCommunity(tenantID, id string, patch *CommunityPatch) (*Community, error)
|
||||
DeleteCommunity(tenantID, id string) error
|
||||
|
||||
// ListPeers returns all BGP peers for a tenant (control plane may paginate in httpapi).
|
||||
ListPeers(tenantID string) []*BGPPeer
|
||||
GetPeer(tenantID, id string) (*BGPPeer, error)
|
||||
CreatePeer(tenantID string, in *BGPPeer) (*BGPPeer, error)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package store
|
||||
|
||||
import "strconv"
|
||||
|
||||
// PaginateOffset returns a page from a pre-sorted slice using numeric string cursors (same scheme as ListRevisions).
|
||||
func PaginateOffset[T any](all []T, cursor string, limit int) (page []T, nextCursor string, hasMore bool) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
off := 0
|
||||
if cursor != "" {
|
||||
if n, err := strconv.Atoi(cursor); err == nil && n >= 0 {
|
||||
off = n
|
||||
}
|
||||
}
|
||||
if off > len(all) {
|
||||
off = len(all)
|
||||
}
|
||||
end := off + limit
|
||||
if end > len(all) {
|
||||
end = len(all)
|
||||
}
|
||||
page = all[off:end]
|
||||
if end < len(all) {
|
||||
nextCursor = strconv.Itoa(end)
|
||||
hasMore = true
|
||||
}
|
||||
return page, nextCursor, hasMore
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env sh
|
||||
# Engineering gates for internal/httpapi (ERR-01, ARCH-01).
|
||||
set -eu
|
||||
|
||||
ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
|
||||
cd "$ROOT"
|
||||
|
||||
HTTPAPI="internal/httpapi"
|
||||
FAIL=0
|
||||
|
||||
echo "==> ERR-01: no err.Error() in 5xx problem details"
|
||||
if grep -rE 'writeProblem\(w, http\.StatusInternalServerError.*err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
if grep -rE 'writeProblem\(w, http\.StatusBadGateway.*err\.Error\(\)' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
echo "==> ARCH-01: no SQL/pgx queries in httpapi"
|
||||
if grep -rE 'pool\.(Query|Exec|QueryRow)|SELECT |INSERT INTO |UPDATE .* SET |DELETE FROM ' "$HTTPAPI" 2>/dev/null; then
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
echo "lint-httpapi: failed" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "lint-httpapi: ok"
|
||||
Reference in new issue
Block a user