diff --git a/cmd/evobgp-node/main.go b/cmd/evobgp-node/main.go index 3269583..2e42f15 100644 --- a/cmd/evobgp-node/main.go +++ b/cmd/evobgp-node/main.go @@ -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:])) } diff --git a/internal/httpapi/list_params.go b/internal/httpapi/list_params.go new file mode 100644 index 0000000..0a586c9 --- /dev/null +++ b/internal/httpapi/list_params.go @@ -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 +} diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 06f378f..555876a 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -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) { diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 2f8706a..701ea6e 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -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, }) } diff --git a/internal/importer/csv.go b/internal/importer/csv.go new file mode 100644 index 0000000..792c854 --- /dev/null +++ b/internal/importer/csv.go @@ -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 +} diff --git a/internal/nodecli/commands.go b/internal/nodecli/commands.go new file mode 100644 index 0000000..6a6b774 --- /dev/null +++ b/internal/nodecli/commands.go @@ -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 +} diff --git a/internal/nodecli/nodecli.go b/internal/nodecli/nodecli.go new file mode 100644 index 0000000..d000fe7 --- /dev/null +++ b/internal/nodecli/nodecli.go @@ -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. +`) +} diff --git a/internal/reports/catalog.go b/internal/reports/catalog.go new file mode 100644 index 0000000..6f66e12 --- /dev/null +++ b/internal/reports/catalog.go @@ -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 +} diff --git a/internal/reports/diagnostic.go b/internal/reports/diagnostic.go new file mode 100644 index 0000000..e66c7f3 --- /dev/null +++ b/internal/reports/diagnostic.go @@ -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] +} diff --git a/internal/store/paginate.go b/internal/store/paginate.go new file mode 100644 index 0000000..b0a793b --- /dev/null +++ b/internal/store/paginate.go @@ -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 +}