diff --git a/internal/birddeploy/apply.go b/internal/birddeploy/apply.go index 68e3915..e8a6fee 100644 --- a/internal/birddeploy/apply.go +++ b/internal/birddeploy/apply.go @@ -43,6 +43,9 @@ func ApplyRevision(ctx context.Context, ctl *birdfmt.BirdCtl, rev *store.Revisio // Phase 1: write staging tree for rel, content := range rev.PreviewFragments { rel = strings.TrimPrefix(rel, "/") + if !isDeployableBirdFragment(rel) { + continue + } dst := filepath.Join(staging, rel) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return err @@ -70,6 +73,9 @@ func ApplyRevision(ctx context.Context, ctl *birdfmt.BirdCtl, rev *store.Revisio // Phase 2: atomic swap into active for rel, content := range rev.PreviewFragments { rel = strings.TrimPrefix(rel, "/") + if !isDeployableBirdFragment(rel) { + continue + } dst := filepath.Join(active, rel) if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { _ = restoreLKG(lkg, active) @@ -93,6 +99,11 @@ func ApplyRevision(ctx context.Context, ctl *birdfmt.BirdCtl, rev *store.Revisio return nil } +// isDeployableBirdFragment skips UI-only preview keys (e.g. flattened full config) not referenced from bird.conf. +func isDeployableBirdFragment(rel string) bool { + return rel != "" && !strings.HasPrefix(rel, "_") +} + func snapshotBirdTree(activeRoot, dstRoot string) error { if err := os.MkdirAll(dstRoot, 0o755); err != nil { return err diff --git a/internal/bundle/pack.go b/internal/bundle/pack.go index 90d2e70..b8d2778 100644 --- a/internal/bundle/pack.go +++ b/internal/bundle/pack.go @@ -47,6 +47,9 @@ func BuildGzippedTar(revisionID, speakerID string, fragments map[string]string, if p == "." || strings.HasPrefix(p, "..") { return nil, fmt.Errorf("bundle: invalid path %q", p) } + if strings.HasPrefix(p, "_") { + continue // UI-only preview aggregate, not shipped to nodes + } norm[p] = content } diff --git a/internal/httpapi/routes.go b/internal/httpapi/routes.go index 209ed40..f5ccb32 100644 --- a/internal/httpapi/routes.go +++ b/internal/httpapi/routes.go @@ -247,10 +247,6 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error()) return } - if mod.Type == "IP_RANGES" { - writeNoContent(w) - return - } idem := r.Header.Get("Idempotency-Key") var idemPtr *string if strings.TrimSpace(idem) != "" { @@ -320,6 +316,22 @@ func strPtrOrNull(s string) any { 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 { + return + } + mod, err := s.store.GetModule(tenantID, moduleID) + if err != nil || !mod.Enabled { + return + } + mid := moduleID + _, _, _ = s.jobs.Enqueue(tenantID, jobs.KindModuleRefresh, nil, &mid, map[string]any{ + "module_id": moduleID, + "trigger": trigger, + }) +} + func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) { a, ok := authFromContext(r.Context()) if !ok { diff --git a/internal/httpapi/routes_crud.go b/internal/httpapi/routes_crud.go index 9fe8a17..b74c777 100644 --- a/internal/httpapi/routes_crud.go +++ b/internal/httpapi/routes_crud.go @@ -400,11 +400,13 @@ func (s *Server) handlePostIPRange(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } - x, err := s.store.CreateIPRangeEntry(a.TenantID, r.PathValue("module_id"), &body) + mid := r.PathValue("module_id") + x, err := s.store.CreateIPRangeEntry(a.TenantID, mid, &body) if err != nil { writeStoreErr(w, err) return } + s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_create") writeJSON(w, http.StatusCreated, ipRangeJSON(x)) } @@ -418,11 +420,13 @@ func (s *Server) handlePatchIPRange(w http.ResponseWriter, r *http.Request) { writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid json") return } - x, err := s.store.UpdateIPRangeEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id"), &body) + mid := r.PathValue("module_id") + x, err := s.store.UpdateIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id"), &body) if err != nil { writeStoreErr(w, err) return } + s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_patch") writeJSON(w, http.StatusOK, ipRangeJSON(x)) } @@ -431,10 +435,12 @@ func (s *Server) handleDeleteIPRange(w http.ResponseWriter, r *http.Request) { if !ok || !s.requireAtLeast(w, a, "editor") { return } - if err := s.store.DeleteIPRangeEntry(a.TenantID, r.PathValue("module_id"), r.PathValue("entry_id")); err != nil { + mid := r.PathValue("module_id") + if err := s.store.DeleteIPRangeEntry(a.TenantID, mid, r.PathValue("entry_id")); err != nil { writeStoreErr(w, err) return } + s.enqueueModuleRefreshIfEnabled(a.TenantID, mid, "ip_range_delete") w.WriteHeader(http.StatusNoContent) } diff --git a/internal/httpapi/server_test.go b/internal/httpapi/server_test.go index 9f8c19e..52d612d 100644 --- a/internal/httpapi/server_test.go +++ b/internal/httpapi/server_test.go @@ -68,7 +68,7 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) { } }) - t.Run("refresh IP_RANGES no op", func(t *testing.T) { + t.Run("refresh IP_RANGES queues render job", func(t *testing.T) { req, _ := http.NewRequest(http.MethodPost, base+"/v1/modules/"+modIP+"/refresh", nil) req.Header.Set("Authorization", "Bearer opkey") resp, err := client.Do(req) @@ -76,10 +76,17 @@ func TestAPIRefreshApplyJobsBundle(t *testing.T) { t.Fatal(err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusNoContent { + if resp.StatusCode != http.StatusAccepted { b, _ := io.ReadAll(resp.Body) t.Fatalf("status %d: %s", resp.StatusCode, b) } + var body struct { + JobID string `json:"job_id"` + } + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + t.Fatal(err) + } + waitJob(t, client, base, "opkey", body.JobID) }) t.Run("refresh CDN queues job", func(t *testing.T) { diff --git a/internal/pipeline/refresh.go b/internal/pipeline/refresh.go index 00f7c52..556a157 100644 --- a/internal/pipeline/refresh.go +++ b/internal/pipeline/refresh.go @@ -3,6 +3,7 @@ package pipeline import ( "context" "crypto/sha256" + "encoding/json" "fmt" "io" "net/http" @@ -17,6 +18,12 @@ import ( "github.com/google/uuid" ) +const ( + birdFilterNameV4 = "evobgp_export_v4" + birdFilterNameV6 = "evobgp_export_v6" + auxBirdFullExpanded = "_bird_full_expanded.conf" +) + // MaterializedASPrefixKey returns the revision snapshot key for an AS-only entry (not a CIDR). func MaterializedASPrefixKey(asn int64) string { return fmt.Sprintf("as:%d", asn) @@ -127,7 +134,7 @@ func RefreshModule(ctx context.Context, st store.Backend, hc *http.Client, tenan revisionID = uuid.NewString() parent := parentRevision(st, tenantID, moduleID) hash := hashMaterialization(moduleID, rows) - preview, err := buildPreviewFragments(revisionID, rows) + preview, err := buildPreviewFragments(st, tenantID, moduleID, revisionID, rows) if err != nil { return "", err } @@ -179,7 +186,7 @@ func hashMaterialization(moduleID string, rows []store.PrefixRow) string { return fmt.Sprintf("sha256:%x", h.Sum(nil)) } -func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[string]string, error) { +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 { @@ -202,29 +209,237 @@ func buildPreviewFragments(revisionID string, rows []store.PrefixRow) (map[strin v6 = append(v6, pfx.Masked()) } } - f4, err := birdfmt.RenderExportFilterIPv4("evobgp_export_v4", v4, pathASNs) + f4, err := birdfmt.RenderExportFilterIPv4(birdFilterNameV4, v4, pathASNs) if err != nil { return nil, err } - f6, err := birdfmt.RenderExportFilterIPv6("evobgp_export_v6", v6, pathASNs) + f6, err := birdfmt.RenderExportFilterIPv6(birdFilterNameV6, v6, pathASNs) if err != nil { return nil, err } - birdD := birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f4, f6) - main := `# EvoBGP generated (pipeline refresh) -router id 192.0.2.1; -include "bird.d/evobgp_generated.conf"; + staticV4 := birdfmt.RenderStaticIPv4Protocol("evobgp_prefixes_v4", v4) + staticV6 := birdfmt.RenderStaticIPv6Protocol("evobgp_prefixes_v6", v6) -protocol device { + locals := birdLocalsFromStore(st, tenantID) + peersBody, err := renderPeersBirdFragment(st, tenantID, locals) + if err != nil { + return nil, err + } + + main, err := birdfmt.RenderMainBirdConf(birdfmt.MainBirdConfOptions{ + RouterID: locals.routerID, + Includes: birdfmt.StandardIncludeFragments(), + Preamble: fmt.Sprintf("EvoBGP module %s revision %s", moduleID, revisionID), + }) + if err != nil { + return nil, err + } + + p4 := birdfmt.FragmentIncludePath(birdfmt.FragmentFiltersV4) + p6 := birdfmt.FragmentIncludePath(birdfmt.FragmentFiltersV6) + px4 := birdfmt.FragmentIncludePath(birdfmt.FragmentPrefixesV4) + px6 := birdfmt.FragmentIncludePath(birdfmt.FragmentPrefixesV6) + pPeers := birdfmt.FragmentIncludePath(birdfmt.FragmentPeers) + + out := map[string]string{ + "bird.conf": main, + p4: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f4), + p6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), f6), + px4: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV4), + px6: birdfmt.JoinFragments(birdfmt.ManagedBanner(revisionID), staticV6), + pPeers: peersBody, + } + out[auxBirdFullExpanded] = buildExpandedBirdText(main, out) + return out, nil } -protocol direct { - ipv4; - ipv6; +type birdLocals struct { + routerID string + localV4 string + localV6 string + localASN uint32 } -` - return map[string]string{ - "bird.conf": main, - "bird.d/evobgp_generated.conf": birdD, - }, nil + +func birdLocalsFromStore(st store.Backend, tenantID string) birdLocals { + def := birdLocals{ + routerID: "192.0.2.1", + localV4: "192.0.2.1", + localV6: "2001:db8::1", + localASN: 65001, + } + settings, err := st.ListGlobalSettings(tenantID) + if err != nil { + return def + } + loc := def + if s := stringFromSettingsMap(settings, "bird_router_id"); s != "" { + loc.routerID = s + } + if s := stringFromSettingsMap(settings, "bird_local_ipv4"); s != "" { + loc.localV4 = s + } + if s := stringFromSettingsMap(settings, "bird_local_ipv6"); s != "" { + loc.localV6 = s + } + if n := uint32FromSettingsMap(settings, "bird_local_asn"); n != 0 { + loc.localASN = n + } + return loc +} + +func stringFromSettingsMap(m map[string]any, key string) string { + v, ok := m[key] + if !ok || v == nil { + return "" + } + s, ok := v.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +func uint32FromSettingsMap(m map[string]any, key string) uint32 { + v, ok := m[key] + if !ok || v == nil { + return 0 + } + switch x := v.(type) { + case float64: + if x >= 1 && x <= 4294967295 { + return uint32(x) + } + case int: + if x >= 1 && x <= 4294967295 { + return uint32(x) + } + case int64: + if x >= 1 && x <= 4294967295 { + return uint32(x) + } + case string: + if n, err := strconv.ParseUint(strings.TrimSpace(x), 10, 32); err == nil && n >= 1 { + return uint32(n) + } + } + return 0 +} + +type peerPolicyJSON struct { + LocalIPv4 string `json:"local_ipv4"` + LocalIPv6 string `json:"local_ipv6"` + LocalASN float64 `json:"local_asn"` +} + +func renderPeersBirdFragment(st store.Backend, tenantID string, loc birdLocals) (string, error) { + peers := st.ListPeers(tenantID) + var parts []string + parts = append(parts, birdfmt.ManagedBanner("peers")) + for _, p := range peers { + if p == nil || !p.Enabled { + continue + } + neighbor := strings.TrimSpace(p.Neighbor) + if neighbor == "" { + continue + } + addr, err := netip.ParseAddr(neighbor) + if err != nil { + continue + } + if !store.ValidASN(p.RemoteASN) { + continue + } + pol := parsePeerPolicies(p.PoliciesJSON) + lv4 := loc.localV4 + if strings.TrimSpace(pol.LocalIPv4) != "" { + lv4 = strings.TrimSpace(pol.LocalIPv4) + } + lv6 := loc.localV6 + if strings.TrimSpace(pol.LocalIPv6) != "" { + lv6 = strings.TrimSpace(pol.LocalIPv6) + } + asn := loc.localASN + if pol.LocalASN >= 1 && pol.LocalASN <= 4294967295 { + asn = uint32(pol.LocalASN) + } + proto := peerProtocolName(p.ID) + ra := uint32(p.RemoteASN) + if addr.Is4() { + s, err := birdfmt.RenderProtocolBGPIPv4(birdfmt.BGPPeerIPv4Options{ + ProtocolName: proto, + LocalIP: lv4, + LocalASN: asn, + NeighborIP: addr.String(), + NeighborASN: ra, + ExportFilter: birdFilterNameV4, + }) + if err != nil { + return "", err + } + parts = append(parts, s) + continue + } + if addr.Is6() { + s, err := birdfmt.RenderProtocolBGPIPv6(birdfmt.BGPPeerIPv6Options{ + ProtocolName: proto, + LocalIP: lv6, + LocalASN: asn, + NeighborIP: addr.String(), + NeighborASN: ra, + ExportFilter: birdFilterNameV6, + }) + if err != nil { + return "", err + } + parts = append(parts, s) + } + } + if len(parts) == 1 { + parts = append(parts, "# (no enabled BGP peers with valid neighbor addresses)\n") + } + return birdfmt.JoinFragments(parts...), nil +} + +func parsePeerPolicies(raw string) peerPolicyJSON { + raw = strings.TrimSpace(raw) + if raw == "" || raw == "{}" { + return peerPolicyJSON{} + } + var pol peerPolicyJSON + _ = json.Unmarshal([]byte(raw), &pol) + return pol +} + +func peerProtocolName(peerID string) string { + s := strings.ReplaceAll(strings.TrimSpace(peerID), "-", "") + if len(s) > 16 { + s = s[:16] + } + if s == "" { + s = "x" + } + return "evobgp_p_" + s +} + +// buildExpandedBirdText concatenates bird.conf and the contents of each standard include (for UI / preview). +func buildExpandedBirdText(main string, frags map[string]string) string { + var b strings.Builder + b.WriteString(strings.TrimSpace(main)) + b.WriteString("\n") + for _, inc := range birdfmt.StandardIncludeFragments() { + b.WriteString("\n# ---------- include \"") + b.WriteString(inc) + b.WriteString("\" ----------\n") + body := strings.TrimSpace(frags[inc]) + if body == "" { + b.WriteString("# (empty)\n") + continue + } + b.WriteString(body) + if !strings.HasSuffix(body, "\n") { + b.WriteByte('\n') + } + } + return b.String() } diff --git a/web/src/routes/modules/[moduleId]/+page.svelte b/web/src/routes/modules/[moduleId]/+page.svelte index bc52686..a644077 100644 --- a/web/src/routes/modules/[moduleId]/+page.svelte +++ b/web/src/routes/modules/[moduleId]/+page.svelte @@ -211,8 +211,7 @@ method: 'POST', headers: { Authorization: `Bearer ${localStorage.getItem('evobgp_api_token') ?? ''}` } }); - if (res.status === 204) toast.message('Refresh не требуется (IP_RANGES)'); - else if (res.status === 202) toast.success('Задача поставлена в очередь'); + if (res.status === 202) toast.success('Задача поставлена в очередь (render ревизии)'); else toast.error(`HTTP ${res.status}`); } catch (e) { toast.error(e instanceof Error ? e.message : String(e)); diff --git a/web/src/routes/operations/+page.svelte b/web/src/routes/operations/+page.svelte index 8e22f20..3a56e9d 100644 --- a/web/src/routes/operations/+page.svelte +++ b/web/src/routes/operations/+page.svelte @@ -157,7 +157,12 @@ async function doApply() { applying = true; try { - await apiMutate('/v1/apply', 'POST', {}); + const revId = revisions[0]?.id; + if (!revId) { + toast.error('Нет ревизий — сначала refresh модуля или дождитесь задачи render'); + return; + } + await apiMutate('/v1/apply', 'POST', { revision_id: revId }); toast.success('Apply запущен на всех спикерах'); applyConfirm = false; await loadJobs(); @@ -265,7 +270,7 @@
История ревизий - Автоматически создаются при apply + Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с инклюдами