feat: enhance RefreshModule to support legacy ASN resolution and improve prefix handling. Introduce sorting of ASNs and manage announced prefixes with polite pauses for better API interaction. Update buildPreviewFragments to streamline route rendering for BIRD configurations.
CI / changes (push) Successful in 10s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 35s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m27s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m16s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 19s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m17s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m48s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m49s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m28s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m35s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m15s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m27s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m23s
CI / changes (push) Successful in 10s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 35s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m27s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m16s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 19s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 1m17s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 1m48s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m49s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m28s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m35s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m15s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m27s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m23s
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
// Package asnresolve fetches IP prefixes announced by an ASN (control-plane ingest).
|
||||
package asnresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultRIPEStatURL is the RIPEstat announced-prefixes data call (no API key).
|
||||
const DefaultRIPEStatURL = "https://stat.ripe.net/data/announced-prefixes/data.json"
|
||||
|
||||
// AnnouncedPrefixes returns currently announced IPv4/IPv6 prefixes for the ASN (best-effort via RIPEstat).
|
||||
func AnnouncedPrefixes(ctx context.Context, hc *http.Client, asn int64) ([]netip.Prefix, error) {
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
base := strings.TrimSpace(os.Getenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL"))
|
||||
if base == "" {
|
||||
base = DefaultRIPEStatURL
|
||||
}
|
||||
u := fmt.Sprintf("%s?resource=AS%d", strings.TrimSuffix(base, "?"), asn)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "evobgp-asnresolve/1.0")
|
||||
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ripestat fetch AS%d: %w", asn, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 32<<20))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("ripestat AS%d: HTTP %s: %s", asn, resp.Status, truncateForErr(body, 200))
|
||||
}
|
||||
|
||||
var wrap struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Prefixes []struct {
|
||||
Prefix string `json:"prefix"`
|
||||
} `json:"prefixes"`
|
||||
} `json:"data"`
|
||||
Messages [][]string `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &wrap); err != nil {
|
||||
return nil, fmt.Errorf("ripestat AS%d: json: %w", asn, err)
|
||||
}
|
||||
if wrap.Status != "" && wrap.Status != "ok" {
|
||||
return nil, fmt.Errorf("ripestat AS%d: status %q", asn, wrap.Status)
|
||||
}
|
||||
|
||||
var out []netip.Prefix
|
||||
for _, row := range wrap.Data.Prefixes {
|
||||
p := strings.TrimSpace(row.Prefix)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, pfx.Masked())
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func truncateForErr(b []byte, n int) string {
|
||||
s := string(b)
|
||||
if len(s) > n {
|
||||
return s[:n] + "…"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// PolitePause is a short delay between upstream ASN lookups (same refresh).
|
||||
func PolitePause() {
|
||||
d := 150 * time.Millisecond
|
||||
if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE_PAUSE_MS")); s != "" {
|
||||
if ms, err := parsePositiveInt(s); err == nil && ms > 0 {
|
||||
d = time.Duration(ms) * time.Millisecond
|
||||
}
|
||||
}
|
||||
time.Sleep(d)
|
||||
}
|
||||
|
||||
func parsePositiveInt(s string) (int, error) {
|
||||
n, err := strconv.Atoi(s)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, fmt.Errorf("invalid")
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package asnresolve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnnouncedPrefixes_Mock(t *testing.T) {
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("resource") != "AS64496" {
|
||||
t.Fatalf("resource: %q", r.URL.Query().Get("resource"))
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":"ok","data":{"prefixes":[{"prefix":"203.0.113.0/24"},{"prefix":"2001:db8::/32"}]}}`))
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
t.Setenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL", ts.URL)
|
||||
t.Setenv("EVOBGP_ASN_RESOLVE_PAUSE_MS", "1")
|
||||
|
||||
pfx, err := AnnouncedPrefixes(context.Background(), ts.Client(), 64496)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(pfx) != 2 {
|
||||
t.Fatalf("got %d prefixes", len(pfx))
|
||||
}
|
||||
if pfx[0].String() != "2001:db8::/32" || pfx[1].String() != "203.0.113.0/24" {
|
||||
t.Fatalf("order or values: %#v", pfx)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package birdfmt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var standardPairRE = regexp.MustCompile(`^\s*(\d{1,5}):(\d{1,5})\s*$`)
|
||||
|
||||
// RouteCommunityAttrs returns BIRD 2 lines (indented with 4 spaces) for use inside a static `route … { … }` block.
|
||||
// Empty string means no community attributes.
|
||||
func RouteCommunityAttrs(kind, name, valueJSON string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
raw := strings.TrimSpace(valueJSON)
|
||||
if raw == "" {
|
||||
raw = "{}"
|
||||
}
|
||||
|
||||
if s, ok := parseLargeFromJSONLoose(raw); ok {
|
||||
return s, nil
|
||||
}
|
||||
_ = kind // reserved for future kinds (e.g. extended communities)
|
||||
|
||||
if m := standardPairRE.FindStringSubmatch(name); len(m) == 3 {
|
||||
return standardAddLine(m[1], m[2])
|
||||
}
|
||||
|
||||
var obj map[string]any
|
||||
if json.Unmarshal([]byte(raw), &obj) == nil {
|
||||
if v, ok := obj["standard"].(string); ok {
|
||||
if m := standardPairRE.FindStringSubmatch(v); len(m) == 3 {
|
||||
return standardAddLine(m[1], m[2])
|
||||
}
|
||||
}
|
||||
if arr, ok := obj["standard"].([]any); ok && len(arr) == 2 {
|
||||
a, aok := numberToUint32String(arr[0])
|
||||
b, bok := numberToUint32String(arr[1])
|
||||
if aok && bok {
|
||||
return standardAddLine(a, b)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var strVal string
|
||||
if json.Unmarshal([]byte(raw), &strVal) == nil {
|
||||
if m := standardPairRE.FindStringSubmatch(strVal); len(m) == 3 {
|
||||
return standardAddLine(m[1], m[2])
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func standardAddLine(a, b string) (string, error) {
|
||||
ai, err1 := strconv.ParseUint(strings.TrimSpace(a), 10, 16)
|
||||
bi, err2 := strconv.ParseUint(strings.TrimSpace(b), 10, 16)
|
||||
if err1 != nil || err2 != nil {
|
||||
return "", fmt.Errorf("birdfmt: standard community parts must be 0..65535 (%s:%s)", a, b)
|
||||
}
|
||||
return fmt.Sprintf(" bgp_community.add((%d,%d));", ai, bi), nil
|
||||
}
|
||||
|
||||
func parseLargeFromJSONLoose(raw string) (string, bool) {
|
||||
var obj map[string]any
|
||||
if json.Unmarshal([]byte(raw), &obj) != nil {
|
||||
return "", false
|
||||
}
|
||||
arr, ok := obj["large"].([]any)
|
||||
if !ok || len(arr) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for _, row := range arr {
|
||||
triple, ok := row.([]any)
|
||||
if !ok || len(triple) != 3 {
|
||||
continue
|
||||
}
|
||||
a, ok1 := numberToUint32String(triple[0])
|
||||
bb, ok2 := numberToUint32String(triple[1])
|
||||
c, ok3 := numberToUint32String(triple[2])
|
||||
if !ok1 || !ok2 || !ok3 {
|
||||
continue
|
||||
}
|
||||
ai, _ := strconv.ParseUint(a, 10, 32)
|
||||
bi, _ := strconv.ParseUint(bb, 10, 32)
|
||||
ci, _ := strconv.ParseUint(c, 10, 32)
|
||||
fmt.Fprintf(&b, " bgp_large_community.add((%d,%d,%d));\n", ai, bi, ci)
|
||||
}
|
||||
s := strings.TrimRight(b.String(), "\r\n")
|
||||
return s, s != ""
|
||||
}
|
||||
|
||||
func numberToUint32String(v any) (string, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
if x < 0 || x > 4294967295 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(uint64(x), 10), true
|
||||
case json.Number:
|
||||
n, err := x.Int64()
|
||||
if err != nil || n < 0 || n > 4294967295 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(uint64(n), 10), true
|
||||
case int:
|
||||
if x < 0 || x > 4294967295 {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(uint64(x), 10), true
|
||||
case string:
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
return strconv.FormatUint(n, 10), true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package birdfmt
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRouteCommunityAttrs_StandardName(t *testing.T) {
|
||||
s, err := RouteCommunityAttrs("", "65000:120", "{}")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s != " bgp_community.add((65000,120));" {
|
||||
t.Fatalf("got %q", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteCommunityAttrs_LargeJSON(t *testing.T) {
|
||||
s, err := RouteCommunityAttrs("large", "x", `{"large":[[65000,1,2]]}`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s != " bgp_large_community.add((65000,1,2));" {
|
||||
t.Fatalf("got %q", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package birdfmt
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StaticRoute is one static route line in BIRD (optionally with a per-route body).
|
||||
type StaticRoute struct {
|
||||
Prefix netip.Prefix
|
||||
RouteBody string // optional lines inside `route P unreachable { ... }`
|
||||
}
|
||||
|
||||
// RenderStaticIPv4Routes renders a BIRD 2 protocol static block for IPv4.
|
||||
func RenderStaticIPv4Routes(protocolName string, routes []StaticRoute) string {
|
||||
if protocolName == "" {
|
||||
protocolName = "evobgp_prefixes_v4"
|
||||
}
|
||||
type key struct {
|
||||
p string
|
||||
b string
|
||||
}
|
||||
uniq := make(map[key]StaticRoute)
|
||||
for _, r := range routes {
|
||||
if !r.Prefix.Addr().Is4() {
|
||||
continue
|
||||
}
|
||||
p := r.Prefix.Masked()
|
||||
body := strings.TrimSpace(r.RouteBody)
|
||||
uniq[key{p.String(), body}] = StaticRoute{Prefix: p, RouteBody: body}
|
||||
}
|
||||
keys := make([]key, 0, len(uniq))
|
||||
for k := range uniq {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].p != keys[j].p {
|
||||
return keys[i].p < keys[j].p
|
||||
}
|
||||
return keys[i].b < keys[j].b
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("protocol static ")
|
||||
b.WriteString(protocolName)
|
||||
b.WriteString(" {\n ipv4;\n")
|
||||
for _, k := range keys {
|
||||
r := uniq[k]
|
||||
p := r.Prefix.String()
|
||||
if r.RouteBody == "" {
|
||||
b.WriteString(" route ")
|
||||
b.WriteString(p)
|
||||
b.WriteString(" unreachable;\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(" route ")
|
||||
b.WriteString(p)
|
||||
b.WriteString(" unreachable {\n")
|
||||
for _, line := range strings.Split(r.RouteBody, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(" };\n")
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// RenderStaticIPv6Routes renders a BIRD 2 protocol static block for IPv6.
|
||||
func RenderStaticIPv6Routes(protocolName string, routes []StaticRoute) string {
|
||||
if protocolName == "" {
|
||||
protocolName = "evobgp_prefixes_v6"
|
||||
}
|
||||
type key struct {
|
||||
p string
|
||||
b string
|
||||
}
|
||||
uniq := make(map[key]StaticRoute)
|
||||
for _, r := range routes {
|
||||
if !r.Prefix.Addr().Is6() {
|
||||
continue
|
||||
}
|
||||
p := r.Prefix.Masked()
|
||||
body := strings.TrimSpace(r.RouteBody)
|
||||
uniq[key{p.String(), body}] = StaticRoute{Prefix: p, RouteBody: body}
|
||||
}
|
||||
keys := make([]key, 0, len(uniq))
|
||||
for k := range uniq {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
if keys[i].p != keys[j].p {
|
||||
return keys[i].p < keys[j].p
|
||||
}
|
||||
return keys[i].b < keys[j].b
|
||||
})
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString("protocol static ")
|
||||
b.WriteString(protocolName)
|
||||
b.WriteString(" {\n ipv6;\n")
|
||||
for _, k := range keys {
|
||||
r := uniq[k]
|
||||
p := r.Prefix.String()
|
||||
if r.RouteBody == "" {
|
||||
b.WriteString(" route ")
|
||||
b.WriteString(p)
|
||||
b.WriteString(" unreachable;\n")
|
||||
continue
|
||||
}
|
||||
b.WriteString(" route ")
|
||||
b.WriteString(p)
|
||||
b.WriteString(" unreachable {\n")
|
||||
for _, line := range strings.Split(r.RouteBody, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(line, " ") && !strings.HasPrefix(line, "\t") {
|
||||
b.WriteString(" ")
|
||||
}
|
||||
b.WriteString(line)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(" };\n")
|
||||
}
|
||||
b.WriteString("}\n")
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package birdfmt
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderStaticIPv4Routes_WithCommunity(t *testing.T) {
|
||||
p := netip.MustParsePrefix("203.0.113.0/24")
|
||||
s := RenderStaticIPv4Routes("t", []StaticRoute{
|
||||
{Prefix: p, RouteBody: " bgp_community.add((65000,1));"},
|
||||
})
|
||||
if !strings.Contains(s, "route 203.0.113.0/24 unreachable {") {
|
||||
t.Fatalf("missing block: %s", s)
|
||||
}
|
||||
if !strings.Contains(s, "bgp_community.add((65000,1))") {
|
||||
t.Fatalf("missing community: %s", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
)
|
||||
|
||||
// materializeRowsForBird turns prefix rows into export-filter CIDR lists, optional AS_PATH rules, and static routes with per-prefix communities.
|
||||
// Duplicate CIDRs: first occurrence wins for static community; filters use unique CIDRs.
|
||||
func materializeRowsForBird(st store.Backend, tenantID string, rows []store.PrefixRow) (
|
||||
filterV4, filterV6 []netip.Prefix,
|
||||
pathASNs []int64,
|
||||
staticV4, staticV6 []birdfmt.StaticRoute,
|
||||
err error,
|
||||
) {
|
||||
var pathSeen map[int64]struct{}
|
||||
addPath := func(n int64) {
|
||||
if pathSeen == nil {
|
||||
pathSeen = make(map[int64]struct{})
|
||||
}
|
||||
if _, ok := pathSeen[n]; ok {
|
||||
return
|
||||
}
|
||||
pathSeen[n] = struct{}{}
|
||||
pathASNs = append(pathASNs, n)
|
||||
}
|
||||
|
||||
filterSeen := make(map[string]struct{})
|
||||
staticChosen := make(map[string]birdfmt.StaticRoute)
|
||||
|
||||
for _, pr := range rows {
|
||||
p := strings.TrimSpace(pr.Prefix)
|
||||
if strings.HasPrefix(p, "as:") {
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64)
|
||||
if err != nil || !store.ValidASN(n) {
|
||||
continue
|
||||
}
|
||||
addPath(n)
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
pfx = pfx.Masked()
|
||||
key := pfx.String()
|
||||
if _, ok := filterSeen[key]; !ok {
|
||||
filterSeen[key] = struct{}{}
|
||||
if pfx.Addr().Is4() {
|
||||
filterV4 = append(filterV4, pfx)
|
||||
} else {
|
||||
filterV6 = append(filterV6, pfx)
|
||||
}
|
||||
}
|
||||
|
||||
if _, ok := staticChosen[key]; ok {
|
||||
continue
|
||||
}
|
||||
body, err := communityRouteBody(st, tenantID, pr.CommunityID)
|
||||
if err != nil {
|
||||
return nil, nil, nil, nil, nil, err
|
||||
}
|
||||
staticChosen[key] = birdfmt.StaticRoute{Prefix: pfx, RouteBody: body}
|
||||
}
|
||||
|
||||
for _, sr := range staticChosen {
|
||||
if sr.Prefix.Addr().Is4() {
|
||||
staticV4 = append(staticV4, sr)
|
||||
} else if sr.Prefix.Addr().Is6() {
|
||||
staticV6 = append(staticV6, sr)
|
||||
}
|
||||
}
|
||||
return filterV4, filterV6, pathASNs, staticV4, staticV6, nil
|
||||
}
|
||||
|
||||
func communityRouteBody(st store.Backend, tenantID string, cid *string) (string, error) {
|
||||
if cid == nil {
|
||||
return "", nil
|
||||
}
|
||||
id := strings.TrimSpace(*cid)
|
||||
if id == "" {
|
||||
return "", nil
|
||||
}
|
||||
c, err := st.GetCommunity(tenantID, id)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("community %s: %w", id, err)
|
||||
}
|
||||
return birdfmt.RouteCommunityAttrs(c.Kind, c.Name, c.ValueJSON)
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"evobgp/internal/asnresolve"
|
||||
"evobgp/internal/birdfmt"
|
||||
"evobgp/internal/store"
|
||||
|
||||
@@ -62,7 +64,10 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, e := range list {
|
||||
sort.Slice(list, func(i, j int) bool { return list[i].ASN < list[j].ASN })
|
||||
legacy := strings.TrimSpace(os.Getenv("EVOBGP_ASN_RESOLVE")) == "0"
|
||||
seenPfx := make(map[string]struct{})
|
||||
for i, e := range list {
|
||||
if !store.ValidASN(e.ASN) {
|
||||
continue
|
||||
}
|
||||
@@ -71,7 +76,26 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan
|
||||
c := *mod.DefaultCommunityID
|
||||
comm = &c
|
||||
}
|
||||
rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"})
|
||||
if legacy {
|
||||
rows = append(rows, store.PrefixRow{Prefix: MaterializedASPrefixKey(e.ASN), CommunityID: comm, Source: "as_entry"})
|
||||
continue
|
||||
}
|
||||
if i > 0 {
|
||||
asnresolve.PolitePause()
|
||||
}
|
||||
pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, e.ASN)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve AS%d: %w", e.ASN, err)
|
||||
}
|
||||
src := fmt.Sprintf("as:%d", e.ASN)
|
||||
for _, pfx := range pfxs {
|
||||
k := pfx.String()
|
||||
if _, ok := seenPfx[k]; ok {
|
||||
continue
|
||||
}
|
||||
seenPfx[k] = struct{}{}
|
||||
rows = append(rows, store.PrefixRow{Prefix: k, CommunityID: comm, Source: src})
|
||||
}
|
||||
}
|
||||
case "CDN_CIDRS":
|
||||
sources, err := st.ListCDNSources(tenantID, moduleID)
|
||||
@@ -187,27 +211,9 @@ func hashMaterialization(moduleID string, rows []store.PrefixRow) string {
|
||||
}
|
||||
|
||||
func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID string, rows []store.PrefixRow) (map[string]string, error) {
|
||||
var v4, v6 []netip.Prefix
|
||||
var pathASNs []int64
|
||||
for _, pr := range rows {
|
||||
p := strings.TrimSpace(pr.Prefix)
|
||||
if strings.HasPrefix(p, "as:") {
|
||||
n, err := strconv.ParseInt(strings.TrimPrefix(p, "as:"), 10, 64)
|
||||
if err != nil || !store.ValidASN(n) {
|
||||
continue
|
||||
}
|
||||
pathASNs = append(pathASNs, n)
|
||||
continue
|
||||
}
|
||||
pfx, err := netip.ParsePrefix(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if pfx.Addr().Is4() {
|
||||
v4 = append(v4, pfx.Masked())
|
||||
} else {
|
||||
v6 = append(v6, pfx.Masked())
|
||||
}
|
||||
v4, v6, pathASNs, sr4, sr6, err := materializeRowsForBird(st, tenantID, rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f4, err := birdfmt.RenderExportFilterIPv4(birdFilterNameV4, v4, pathASNs)
|
||||
if err != nil {
|
||||
@@ -217,8 +223,8 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
staticV4 := birdfmt.RenderStaticIPv4Protocol("evobgp_prefixes_v4", v4)
|
||||
staticV6 := birdfmt.RenderStaticIPv6Protocol("evobgp_prefixes_v6", v6)
|
||||
staticV4 := birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4", sr4)
|
||||
staticV6 := birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6", sr6)
|
||||
|
||||
locals := birdLocalsFromStore(st, tenantID)
|
||||
peersBody, err := renderPeersBirdFragment(st, tenantID, locals)
|
||||
|
||||
@@ -505,8 +505,10 @@
|
||||
|
||||
<!-- Preview dialog -->
|
||||
<Dialog bind:open={previewDialog}>
|
||||
<DialogContent class="sm:max-w-4xl">
|
||||
<DialogHeader>
|
||||
<DialogContent
|
||||
class="!flex max-h-[min(96vh,880px)] w-[min(100vw-2rem,56rem)] flex-col gap-4 overflow-hidden sm:max-w-4xl"
|
||||
>
|
||||
<DialogHeader class="shrink-0 space-y-1.5 pr-8">
|
||||
<DialogTitle>Ревизия {previewRevision?.id.slice(0, 8)}…</DialogTitle>
|
||||
<DialogDescription>
|
||||
Срендеренный конфиг BIRD 2 (фрагменты из control plane) и материализованные префиксы.
|
||||
@@ -515,48 +517,59 @@
|
||||
{#if previewLoading}
|
||||
<p class="text-muted-foreground py-4 text-center text-sm">Загрузка…</p>
|
||||
{:else}
|
||||
<Tabs bind:value={previewSubTab} class="w-full">
|
||||
<TabsList class="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="bird">Конфиг BIRD</TabsTrigger>
|
||||
<TabsTrigger value="prefixes">Префиксы</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="bird" class="mt-3 space-y-2">
|
||||
{@const frags = asPreviewFragments(previewData)}
|
||||
{#if Object.keys(frags).length === 0}
|
||||
<p class="text-muted-foreground text-sm">Нет фрагментов превью (старая ревизия или пустой render).</p>
|
||||
{:else}
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<label for="bird-frag" class="text-muted-foreground text-sm">Файл</label>
|
||||
<select
|
||||
id="bird-frag"
|
||||
class="border-input bg-background ring-offset-background focus-visible:ring-ring max-w-xl flex-1 rounded-md border px-2 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
bind:value={birdPreviewPath}
|
||||
>
|
||||
{#each Object.keys(frags).sort() as path}
|
||||
<option value={path}>{path}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-muted-foreground text-xs">
|
||||
Совет: откройте <code class="bg-muted rounded px-1">_bird_full_expanded.conf</code> — один текст с
|
||||
<code class="bg-muted rounded px-1">bird.conf</code> и содержимым всех include.
|
||||
</p>
|
||||
<ScrollArea class="max-h-[min(70vh,28rem)] rounded border">
|
||||
<pre class="p-3 font-mono text-xs whitespace-pre-wrap break-words">{frags[birdPreviewPath] ?? ''}</pre>
|
||||
</ScrollArea>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
<TabsContent value="prefixes" class="mt-3">
|
||||
<p class="text-sm mb-2"><span class="font-medium">Префиксов:</span> {prefixesData.length}</p>
|
||||
<ScrollArea class="max-h-80 rounded border p-3">
|
||||
{#each prefixesData as pfx}
|
||||
<p class="font-mono text-xs">{pfx}</p>
|
||||
<div class="flex min-h-0 min-w-0 flex-1 flex-col">
|
||||
<Tabs bind:value={previewSubTab} class="flex min-h-0 flex-1 flex-col gap-3">
|
||||
<TabsList class="grid w-full max-w-md shrink-0 grid-cols-2">
|
||||
<TabsTrigger value="bird">Конфиг BIRD</TabsTrigger>
|
||||
<TabsTrigger value="prefixes">Префиксы</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="bird" class="mt-0 flex min-h-0 flex-1 flex-col gap-2 outline-none">
|
||||
{@const frags = asPreviewFragments(previewData)}
|
||||
{#if Object.keys(frags).length === 0}
|
||||
<p class="text-muted-foreground text-sm">Нет фрагментов превью (старая ревизия или пустой render).</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет префиксов</p>
|
||||
{/each}
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<label for="bird-frag" class="text-muted-foreground text-sm">Файл</label>
|
||||
<select
|
||||
id="bird-frag"
|
||||
class="border-input bg-background ring-offset-background focus-visible:ring-ring max-w-xl min-w-0 flex-1 rounded-md border px-2 py-1.5 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
||||
bind:value={birdPreviewPath}
|
||||
>
|
||||
{#each Object.keys(frags).sort() as path}
|
||||
<option value={path}>{path}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<p class="text-muted-foreground shrink-0 text-xs">
|
||||
Совет: откройте <code class="bg-muted rounded px-1">_bird_full_expanded.conf</code> — один текст с
|
||||
<code class="bg-muted rounded px-1">bird.conf</code> и содержимым всех include.
|
||||
</p>
|
||||
<div
|
||||
class="border-border bg-muted/40 text-foreground relative isolate min-h-[12rem] min-w-0 flex-1 overflow-x-auto overflow-y-auto rounded-lg border [scrollbar-gutter:stable] overscroll-contain"
|
||||
>
|
||||
<pre
|
||||
class="text-foreground m-0 block min-w-0 w-full max-w-none p-3 font-mono text-xs leading-relaxed whitespace-pre-wrap break-words select-text"
|
||||
>{frags[birdPreviewPath] ?? ''}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</TabsContent>
|
||||
<TabsContent value="prefixes" class="mt-0 flex min-h-0 flex-1 flex-col gap-2 outline-none">
|
||||
<p class="text-sm shrink-0">
|
||||
<span class="font-medium">Префиксов:</span>
|
||||
{prefixesData.length}
|
||||
</p>
|
||||
<div
|
||||
class="border-border bg-muted/40 min-h-[10rem] min-w-0 flex-1 overflow-x-auto overflow-y-auto rounded-lg border p-3 [scrollbar-gutter:stable] overscroll-contain"
|
||||
>
|
||||
{#each prefixesData as pfx}
|
||||
<p class="font-mono text-xs">{pfx}</p>
|
||||
{:else}
|
||||
<p class="text-muted-foreground text-xs">Нет префиксов</p>
|
||||
{/each}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
{/if}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user