feat: refactor materialization logic to group static routes by community in the refresh pipeline. Introduce a new structure for managing static routes, enhancing the handling of IPv4 and IPv6 routes while preserving community associations. Update related functions to support the new grouping mechanism.
CI / changes (push) Successful in 5s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 38s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Successful in 15s
CI / docker-go-prime (push) Successful in 24s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Successful in 59s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Successful in 2m6s
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Successful in 1m22s
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Successful in 1m21s
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Successful in 1m24s
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Successful in 1m8s
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Successful in 1m23s
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Successful in 1m22s

This commit is contained in:
Denozordec
2026-04-06 20:20:52 +07:00
parent 0428b83eb5
commit 1c5a9c91bb
3 changed files with 204 additions and 27 deletions
+72 -17
View File
@@ -3,6 +3,7 @@ package pipeline
import (
"fmt"
"net/netip"
"sort"
"strconv"
"strings"
@@ -10,12 +11,18 @@ import (
"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.
type staticCommunityRoutes struct {
CommunityID string
RoutesV4 []birdfmt.StaticRoute
RoutesV6 []birdfmt.StaticRoute
}
// materializeRowsForBird turns prefix rows into export-filter CIDR lists, optional AS_PATH rules,
// and static routes grouped by community. The same prefix can appear in multiple community groups.
func materializeRowsForBird(st store.Backend, tenantID string, rows []store.PrefixRow) (
filterV4, filterV6 []netip.Prefix,
pathASNs []int64,
staticV4, staticV6 []birdfmt.StaticRoute,
staticByCommunity []staticCommunityRoutes,
err error,
) {
var pathSeen map[int64]struct{}
@@ -31,7 +38,12 @@ func materializeRowsForBird(st store.Backend, tenantID string, rows []store.Pref
}
filterSeen := make(map[string]struct{})
staticChosen := make(map[string]birdfmt.StaticRoute)
type staticGroup struct {
v4 map[string]birdfmt.StaticRoute
v6 map[string]birdfmt.StaticRoute
}
staticGroups := make(map[string]*staticGroup)
routeBodyByCommunity := make(map[string]string)
for _, pr := range rows {
p := strings.TrimSpace(pr.Prefix)
@@ -58,24 +70,67 @@ func materializeRowsForBird(st store.Backend, tenantID string, rows []store.Pref
}
}
if _, ok := staticChosen[key]; ok {
continue
communityID := ""
if pr.CommunityID != nil {
communityID = strings.TrimSpace(*pr.CommunityID)
}
body, err := communityRouteBody(st, tenantID, pr.CommunityID)
if err != nil {
return nil, nil, nil, nil, nil, err
body, ok := routeBodyByCommunity[communityID]
if !ok {
body, err = communityRouteBody(st, tenantID, pr.CommunityID)
if err != nil {
return nil, nil, nil, nil, err
}
routeBodyByCommunity[communityID] = body
}
grp, ok := staticGroups[communityID]
if !ok {
grp = &staticGroup{
v4: make(map[string]birdfmt.StaticRoute),
v6: make(map[string]birdfmt.StaticRoute),
}
staticGroups[communityID] = grp
}
if pfx.Addr().Is4() {
if _, exists := grp.v4[key]; !exists {
grp.v4[key] = birdfmt.StaticRoute{Prefix: pfx, RouteBody: body}
}
} else if pfx.Addr().Is6() {
if _, exists := grp.v6[key]; !exists {
grp.v6[key] = birdfmt.StaticRoute{Prefix: pfx, RouteBody: body}
}
}
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)
}
groupKeys := make([]string, 0, len(staticGroups))
for k := range staticGroups {
groupKeys = append(groupKeys, k)
}
return filterV4, filterV6, pathASNs, staticV4, staticV6, nil
sort.Strings(groupKeys)
for _, gid := range groupKeys {
grp := staticGroups[gid]
out := staticCommunityRoutes{CommunityID: gid}
v4Keys := make([]string, 0, len(grp.v4))
for k := range grp.v4 {
v4Keys = append(v4Keys, k)
}
sort.Strings(v4Keys)
for _, k := range v4Keys {
out.RoutesV4 = append(out.RoutesV4, grp.v4[k])
}
v6Keys := make([]string, 0, len(grp.v6))
for k := range grp.v6 {
v6Keys = append(v6Keys, k)
}
sort.Strings(v6Keys)
for _, k := range v6Keys {
out.RoutesV6 = append(out.RoutesV6, grp.v6[k])
}
staticByCommunity = append(staticByCommunity, out)
}
return filterV4, filterV6, pathASNs, staticByCommunity, nil
}
func communityRouteBody(st store.Backend, tenantID string, cid *string) (string, error) {
@@ -0,0 +1,94 @@
package pipeline
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"evobgp/internal/birdfmt"
"evobgp/internal/store"
)
func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, moduleIP, _, _ := m.DemoIDs()
c1, err := m.CreateCommunity(tenant, &store.Community{Community: "65000:1", Title: "A", ValueJSON: "{}"})
if err != nil {
t.Fatal(err)
}
c2, err := m.CreateCommunity(tenant, &store.Community{Community: "65000:2", Title: "B", ValueJSON: "{}"})
if err != nil {
t.Fatal(err)
}
rows := []store.PrefixRow{
{Prefix: "203.0.113.0/24", CommunityID: &c1.ID, Source: "ip_range"},
{Prefix: "203.0.113.0/24", CommunityID: &c2.ID, Source: "cdn:test"},
}
out, err := buildPreviewFragments(m, tenant, moduleIP, "test-revision", rows)
if err != nil {
t.Fatal(err)
}
staticV4 := out[birdfmt.FragmentIncludePath(birdfmt.FragmentPrefixesV4)]
if !strings.Contains(staticV4, "protocol static evobgp_prefixes_v4_"+communityProtocolSuffix(c1.ID)) {
t.Fatalf("expected dedicated static protocol for first community, got:\n%s", staticV4)
}
if !strings.Contains(staticV4, "protocol static evobgp_prefixes_v4_"+communityProtocolSuffix(c2.ID)) {
t.Fatalf("expected dedicated static protocol for second community, got:\n%s", staticV4)
}
out2, err := buildPreviewFragments(m, tenant, moduleIP, "test-revision", rows)
if err != nil {
t.Fatal(err)
}
staticV4Second := out2[birdfmt.FragmentIncludePath(birdfmt.FragmentPrefixesV4)]
if staticV4 != staticV4Second {
t.Fatalf("expected deterministic static preview text, got first:\n%s\nsecond:\n%s", staticV4, staticV4Second)
}
}
func TestCollectModulePrefixRows_CDNRefreshForcesFullGet(t *testing.T) {
m := store.NewMemory()
m.SeedDemo()
tenant, _, _, _, _ := m.DemoIDs()
mod, err := m.CreateModule(tenant, &store.Module{
Type: "CDN_CIDRS",
Name: "cdn-refresh-force-full",
Enabled: true,
})
if err != nil {
t.Fatal(err)
}
var gotIfNoneMatch string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotIfNoneMatch = strings.TrimSpace(r.Header.Get("If-None-Match"))
w.Header().Set("ETag", "etag-new")
_, _ = w.Write([]byte("198.51.100.0/24\n"))
}))
defer srv.Close()
etag := "etag-old"
if _, err := m.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
SourceKind: "txt",
URL: srv.URL,
Etag: etag,
}); err != nil {
t.Fatal(err)
}
collected, err := collectModulePrefixRows(context.Background(), m, srv.Client(), tenant, mod)
if err != nil {
t.Fatal(err)
}
if gotIfNoneMatch != "" {
t.Fatalf("refresh path must not send If-None-Match, got %q", gotIfNoneMatch)
}
if len(collected) != 1 || collected[0].Prefix != "198.51.100.0/24" {
t.Fatalf("unexpected collected rows: %+v", collected)
}
}
+38 -10
View File
@@ -163,17 +163,10 @@ func collectModulePrefixRows(ctx context.Context, st store.Backend, hc *http.Cli
if err != nil {
return nil, err
}
if strings.TrimSpace(src.Etag) != "" {
req.Header.Set("If-None-Match", strings.TrimSpace(src.Etag))
}
resp, err := hc.Do(req)
if err != nil {
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
}
if resp.StatusCode == http.StatusNotModified {
_ = resp.Body.Close()
continue
}
if resp.StatusCode != http.StatusOK {
_, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close()
@@ -674,7 +667,7 @@ func hashAggregatedMaterialization(tenantID string, rows []store.PrefixRow) stri
}
func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID string, rows []store.PrefixRow) (map[string]string, error) {
v4, v6, pathASNs, sr4, sr6, err := materializeRowsForBird(st, tenantID, rows)
v4, v6, pathASNs, staticGroups, err := materializeRowsForBird(st, tenantID, rows)
if err != nil {
return nil, err
}
@@ -686,8 +679,7 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri
if err != nil {
return nil, err
}
staticV4 := birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4", sr4)
staticV6 := birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6", sr6)
staticV4, staticV6 := renderStaticProtocolsByCommunity(staticGroups)
locals := birdLocalsFromStore(st, tenantID)
tplBody, err := birdfmt.RenderBGPTemplates(birdfmt.BGPTemplatesOptions{
@@ -732,6 +724,42 @@ func buildPreviewFragments(st store.Backend, tenantID, moduleID, revisionID stri
return out, nil
}
func renderStaticProtocolsByCommunity(groups []staticCommunityRoutes) (string, string) {
var b4 strings.Builder
var b6 strings.Builder
for _, grp := range groups {
nameSuffix := communityProtocolSuffix(grp.CommunityID)
if len(grp.RoutesV4) > 0 {
b4.WriteString(birdfmt.RenderStaticIPv4Routes("evobgp_prefixes_v4_"+nameSuffix, grp.RoutesV4))
}
if len(grp.RoutesV6) > 0 {
b6.WriteString(birdfmt.RenderStaticIPv6Routes("evobgp_prefixes_v6_"+nameSuffix, grp.RoutesV6))
}
}
return b4.String(), b6.String()
}
func communityProtocolSuffix(communityID string) string {
raw := strings.TrimSpace(communityID)
if raw == "" {
return "default"
}
var b strings.Builder
b.Grow(len(raw))
for _, r := range raw {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
continue
}
b.WriteByte('_')
}
out := strings.Trim(b.String(), "_")
if out == "" {
return "default"
}
return "c_" + out
}
type birdLocals struct {
routerID string
localV4 string