refactor: phase 2 structural alignment — reports, importer, nodecli, pagination

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-20 00:22:39 +07:00
co-authored by Cursor
parent 1dd713514c
commit 250cdea35d
10 changed files with 895 additions and 686 deletions
+3 -228
View File
@@ -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:]))
}
+24
View File
@@ -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
}
+26 -311
View File
@@ -19,6 +19,7 @@ import (
"evobgp/internal/jobs"
"evobgp/internal/observability"
"evobgp/internal/pipeline"
"evobgp/internal/reports"
"evobgp/internal/store"
)
@@ -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},
})
}
@@ -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,
})
}
@@ -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) {
+27 -147
View File
@@ -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"
)
@@ -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,
})
}
+147
View File
@@ -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
}
+214
View File
@@ -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
}
+38
View File
@@ -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.
`)
}
+151
View File
@@ -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
}
+236
View File
@@ -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]
}
+29
View File
@@ -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
}