feat: add tenant refresh functionality to HTTP API and job processing
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 59s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been skipped
CI / bird2 (push) Successful in 43s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped
CI / docker-go-prime (push) Successful in 55s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been cancelled
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / go (push) Successful in 59s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Has been skipped
CI / docker-bird (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Has been skipped
CI / bird2 (push) Successful in 43s
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped
CI / docker-go-prime (push) Successful in 55s
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been cancelled
Implemented a new endpoint for tenant refresh in the HTTP API, allowing for the refresh of modules associated with a tenant. Enhanced job processing to handle tenant refresh jobs, including logic for managing module IDs and job status updates. Updated the scheduler to enqueue tenant refresh jobs based on module due dates, improving the overall efficiency of module management. Additionally, introduced caching for ASN prefix data to optimize performance during refresh operations.
This commit is contained in:
@@ -18,6 +18,7 @@ import (
|
|||||||
"evobgp/internal/bundle"
|
"evobgp/internal/bundle"
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
"evobgp/internal/observability"
|
"evobgp/internal/observability"
|
||||||
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ func (s *Server) registerV1(m *http.ServeMux) {
|
|||||||
m.HandleFunc("GET /peers", s.handleListPeers)
|
m.HandleFunc("GET /peers", s.handleListPeers)
|
||||||
m.HandleFunc("GET /speakers", s.handleListSpeakers)
|
m.HandleFunc("GET /speakers", s.handleListSpeakers)
|
||||||
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
|
m.HandleFunc("POST /modules/{module_id}/refresh", s.handleModuleRefresh)
|
||||||
|
m.HandleFunc("POST /tenant/refresh", s.handleTenantRefresh)
|
||||||
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
m.HandleFunc("GET /revisions", s.handleListRevisions)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
|
m.HandleFunc("GET /revisions/{revision_id}", s.handleGetRevision)
|
||||||
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
|
m.HandleFunc("GET /revisions/{revision_id}/preview", s.handleRevisionPreview)
|
||||||
@@ -462,6 +464,60 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
|
writeJSON(w, http.StatusAccepted, map[string]any{"job_id": snap["job_id"], "status": snap["status"]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a, ok := authFromContext(r.Context())
|
||||||
|
if !ok {
|
||||||
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.requireAtLeast(w, a, "editor") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
ModuleIDs []string `json:"module_ids"`
|
||||||
|
}
|
||||||
|
if r.Body != nil && r.ContentLength != 0 {
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&body); err != nil && err != io.EOF {
|
||||||
|
writeProblem(w, http.StatusBadRequest, "Bad Request", "invalid JSON body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
moduleIDs := body.ModuleIDs
|
||||||
|
if len(moduleIDs) == 0 {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for _, mod := range s.store.ListModules(a.TenantID) {
|
||||||
|
if pipeline.ModuleDueForScheduler(mod, now) {
|
||||||
|
moduleIDs = append(moduleIDs, mod.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(moduleIDs) == 0 {
|
||||||
|
writeJSON(w, http.StatusNoContent, map[string]any{"message": "no modules due for refresh"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||||
|
var idemPtr *string
|
||||||
|
if idem != "" {
|
||||||
|
idemPtr = &idem
|
||||||
|
}
|
||||||
|
j, _, err := s.jobs.Enqueue(a.TenantID, jobs.KindTenantRefresh, idemPtr, nil, map[string]any{
|
||||||
|
"module_ids": moduleIDs,
|
||||||
|
"trigger": "api",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeProblem(w, http.StatusInternalServerError, "Internal Error", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Location", "/v1/jobs/"+j.ID)
|
||||||
|
snap := j.Snapshot()
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||||
|
"job_id": snap["job_id"],
|
||||||
|
"status": snap["status"],
|
||||||
|
"module_ids": moduleIDs,
|
||||||
|
"modules_due": len(moduleIDs),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
||||||
a, ok := authFromContext(r.Context())
|
a, ok := authFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -346,14 +346,21 @@ func (r *Registry) List(tenantID, statusFilter, kindFilter, cursor string, limit
|
|||||||
// CountOtherActiveModuleRefresh returns how many module_refresh jobs for the tenant are still
|
// CountOtherActiveModuleRefresh returns how many module_refresh jobs for the tenant are still
|
||||||
// queued or running, excluding excludeJobID (the current job). Used to batch deploy_apply.
|
// queued or running, excluding excludeJobID (the current job). Used to batch deploy_apply.
|
||||||
func (r *Registry) CountOtherActiveModuleRefresh(tenantID, excludeJobID string) int {
|
func (r *Registry) CountOtherActiveModuleRefresh(tenantID, excludeJobID string) int {
|
||||||
|
return r.CountOtherActiveRefresh(tenantID, excludeJobID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CountOtherActiveRefresh counts queued/running module_refresh and tenant_refresh jobs for the tenant.
|
||||||
|
func (r *Registry) CountOtherActiveRefresh(tenantID, excludeJobID string) int {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
// Two-phase: snapshot job pointers under r.mu, then read j.Status under Job.mu.
|
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
candidates := make([]*Job, 0, 8)
|
candidates := make([]*Job, 0, 8)
|
||||||
for _, j := range r.byID {
|
for _, j := range r.byID {
|
||||||
if j.TenantID != tenantID || j.Kind != KindModuleRefresh {
|
if j.TenantID != tenantID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if j.Kind != KindModuleRefresh && j.Kind != KindTenantRefresh {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if j.ID == excludeJobID {
|
if j.ID == excludeJobID {
|
||||||
|
|||||||
+47
-1
@@ -42,6 +42,7 @@ func mergeBirdPostApplyMeta(j *Job) {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
KindModuleRefresh = "module_refresh"
|
KindModuleRefresh = "module_refresh"
|
||||||
|
KindTenantRefresh = "tenant_refresh"
|
||||||
KindPeerReconcile = "peer_reconcile"
|
KindPeerReconcile = "peer_reconcile"
|
||||||
KindDeployApply = "deploy_apply"
|
KindDeployApply = "deploy_apply"
|
||||||
KindRevisionRollback = "revision_rollback"
|
KindRevisionRollback = "revision_rollback"
|
||||||
@@ -106,6 +107,8 @@ func (w *Worker) Process(j *Job) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.finishModuleRefreshSuccess(j, mid)
|
w.finishModuleRefreshSuccess(j, mid)
|
||||||
|
case KindTenantRefresh:
|
||||||
|
w.runTenantRefresh(j)
|
||||||
case KindPeerReconcile:
|
case KindPeerReconcile:
|
||||||
w.runPeerReconcile(j)
|
w.runPeerReconcile(j)
|
||||||
case KindDeployApply:
|
case KindDeployApply:
|
||||||
@@ -221,6 +224,49 @@ func (w *Worker) tenantRefreshMu(tenantID string) *sync.Mutex {
|
|||||||
|
|
||||||
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
|
// finishModuleRefreshSuccess marks the refresh job and, for the last active refresh in tenant,
|
||||||
// creates one aggregate revision and enqueues a single deploy_apply.
|
// creates one aggregate revision and enqueues a single deploy_apply.
|
||||||
|
func (w *Worker) runTenantRefresh(j *Job) {
|
||||||
|
moduleIDs := moduleIDsFromJobMeta(j.Meta)
|
||||||
|
if len(moduleIDs) == 0 {
|
||||||
|
j.Fail("missing module_ids in job meta")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := pipeline.RefreshTenantModules(context.Background(), w.Store, w.httpClient(), j.TenantID, moduleIDs); err != nil {
|
||||||
|
j.Fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
trigger, err := pipeline.PickTenantRefreshTriggerModule(w.Store, j.TenantID, moduleIDs)
|
||||||
|
if err != nil {
|
||||||
|
j.Fail(err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
j.mergeMeta(map[string]any{"module_ids": moduleIDs, "modules_refreshed": len(moduleIDs)})
|
||||||
|
w.finishModuleRefreshSuccess(j, trigger)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moduleIDsFromJobMeta(meta map[string]any) []string {
|
||||||
|
if meta == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, ok := meta["module_ids"]
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch v := raw.(type) {
|
||||||
|
case []string:
|
||||||
|
return v
|
||||||
|
case []any:
|
||||||
|
var out []string
|
||||||
|
for _, x := range v {
|
||||||
|
if s, ok := x.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
out = append(out, strings.TrimSpace(s))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
||||||
if w == nil || w.Store == nil {
|
if w == nil || w.Store == nil {
|
||||||
j.Succeed()
|
j.Succeed()
|
||||||
@@ -231,7 +277,7 @@ func (w *Worker) finishModuleRefreshSuccess(j *Job, triggerModuleID string) {
|
|||||||
defer mu.Unlock()
|
defer mu.Unlock()
|
||||||
deferDeploy := false
|
deferDeploy := false
|
||||||
if w.Registry != nil {
|
if w.Registry != nil {
|
||||||
deferDeploy = w.Registry.CountOtherActiveModuleRefresh(j.TenantID, j.ID) > 0
|
deferDeploy = w.Registry.CountOtherActiveRefresh(j.TenantID, j.ID) > 0
|
||||||
}
|
}
|
||||||
if deferDeploy {
|
if deferDeploy {
|
||||||
j.mergeMeta(map[string]any{
|
j.mergeMeta(map[string]any{
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/asnresolve"
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func asnCacheTTL() time.Duration {
|
||||||
|
sec := 1800
|
||||||
|
if s := strings.TrimSpace(os.Getenv("EVOBGP_ASN_CACHE_TTL_SEC")); s != "" {
|
||||||
|
if v, err := strconv.Atoi(s); err == nil && v > 0 {
|
||||||
|
sec = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return time.Duration(sec) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveASNForEntry fetches prefixes and holder with shared TTL cache (asn_prefix_cache).
|
||||||
|
func resolveASNForEntry(ctx context.Context, st store.Backend, hc *http.Client, asn int64) ([]netip.Prefix, string, error) {
|
||||||
|
ttl := asnCacheTTL()
|
||||||
|
if st != nil {
|
||||||
|
if ent, ok, err := st.GetASNPrefixCache(asn); err == nil && ok && ent != nil && time.Since(ent.FetchedAt) < ttl {
|
||||||
|
out := make([]netip.Prefix, 0, len(ent.Prefixes))
|
||||||
|
for _, p := range ent.Prefixes {
|
||||||
|
pfx, perr := netip.ParsePrefix(strings.TrimSpace(p))
|
||||||
|
if perr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, pfx.Masked())
|
||||||
|
}
|
||||||
|
return out, ent.Holder, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, asn)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
holder, _ := asnresolve.ASHolderName(ctx, hc, asn)
|
||||||
|
if st != nil {
|
||||||
|
strs := make([]string, len(pfxs))
|
||||||
|
for i, p := range pfxs {
|
||||||
|
strs[i] = p.String()
|
||||||
|
}
|
||||||
|
if err := st.SetASNPrefixCache(asn, holder, strs); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("asn cache AS%d: %w", asn, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pfxs, holder, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolveASNForEntry_UsesTTLCache(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
t.Setenv("EVOBGP_ASN_CACHE_TTL_SEC", "3600")
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
calls.Add(1)
|
||||||
|
if strings.Contains(r.URL.Path, "/announced") {
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","data":{"prefixes":[{"prefix":"203.0.113.0/24"}]}}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"status":"ok","data":{"holder":"Test AS"}}`))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
t.Setenv("EVOBGP_RIPESTAT_ANNOUNCED_PREFIXES_URL", srv.URL+"/announced")
|
||||||
|
t.Setenv("EVOBGP_RIPESTAT_AS_OVERVIEW_URL", srv.URL+"/overview")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
hc := srv.Client()
|
||||||
|
p1, h1, err := resolveASNForEntry(ctx, m, hc, 64512)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(p1) != 1 || h1 != "Test AS" {
|
||||||
|
t.Fatalf("unexpected first resolve: %+v holder=%q", p1, h1)
|
||||||
|
}
|
||||||
|
if calls.Load() < 2 {
|
||||||
|
t.Fatalf("expected ripestat calls on first resolve, got %d", calls.Load())
|
||||||
|
}
|
||||||
|
firstCalls := calls.Load()
|
||||||
|
_, _, err = resolveASNForEntry(ctx, m, hc, 64512)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls.Load() != firstCalls {
|
||||||
|
t.Fatalf("expected cache hit (no new HTTP), calls went from %d to %d", firstCalls, calls.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestModuleDueForScheduler_BucketRollover(t *testing.T) {
|
||||||
|
mod := &store.Module{Enabled: true, Type: "CDN_CIDRS", RefreshIntervalSec: 300}
|
||||||
|
boundary := time.Unix(300, 0)
|
||||||
|
if !ModuleDueForScheduler(mod, boundary) {
|
||||||
|
t.Fatal("expected due when refresh bucket rolls")
|
||||||
|
}
|
||||||
|
mid := time.Unix(330, 0)
|
||||||
|
if ModuleDueForScheduler(mod, mid) {
|
||||||
|
t.Fatal("expected not due within same bucket")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCollectModulePrefixRows_CDNSendsIfNoneMatch(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
|
||||||
|
mod, err := m.CreateModule(tenant, &store.Module{
|
||||||
|
Type: "CDN_CIDRS",
|
||||||
|
Name: "cdn-conditional",
|
||||||
|
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, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotIfNoneMatch != etag {
|
||||||
|
t.Fatalf("want If-None-Match %q, got %q", etag, gotIfNoneMatch)
|
||||||
|
}
|
||||||
|
if len(collected) != 1 || collected[0].Prefix != "198.51.100.0/24" {
|
||||||
|
t.Fatalf("unexpected collected rows: %+v", collected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCollectModulePrefixRows_CDN304UsesSnapshot(t *testing.T) {
|
||||||
|
m := store.NewMemory()
|
||||||
|
m.SeedDemo()
|
||||||
|
tenant, _, _, _, _ := m.DemoIDs()
|
||||||
|
|
||||||
|
mod, err := m.CreateModule(tenant, &store.Module{
|
||||||
|
Type: "CDN_CIDRS",
|
||||||
|
Name: "cdn-304",
|
||||||
|
Enabled: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotModified)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
etag := "etag-stable"
|
||||||
|
src, err := m.CreateCDNSource(tenant, mod.ID, &store.CDNSource{
|
||||||
|
SourceKind: "txt",
|
||||||
|
URL: srv.URL,
|
||||||
|
Etag: etag,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prior := []store.PrefixRow{{
|
||||||
|
Prefix: "203.0.113.0/24",
|
||||||
|
Source: cdnSourceKey(src.ID),
|
||||||
|
}}
|
||||||
|
if err := mergeCDNSourceIntoModuleSnapshot(m, tenant, mod, src.ID, prior); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
collected, err := collectModulePrefixRows(context.Background(), m, srv.Client(), tenant, mod, prior)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(collected) != 1 || collected[0].Prefix != "203.0.113.0/24" {
|
||||||
|
t.Fatalf("want cached prefix on 304, got %+v", collected)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cdnSourceKey(sourceID string) string {
|
||||||
|
return "cdn:" + strings.TrimSpace(sourceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeSnapshotDropSource(rows []store.PrefixRow, sourceKey string) []store.PrefixRow {
|
||||||
|
if len(rows) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]store.PrefixRow, 0, len(rows))
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.Source != sourceKey {
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func cdnRowsFromParsed(mod *store.Module, src *store.CDNSource, pfxStrings []string) []store.PrefixRow {
|
||||||
|
var rows []store.PrefixRow
|
||||||
|
for _, p := range pfxStrings {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
comm := src.CommunityID
|
||||||
|
if comm == nil && mod != nil && mod.DefaultCommunityID != nil {
|
||||||
|
c := *mod.DefaultCommunityID
|
||||||
|
comm = &c
|
||||||
|
}
|
||||||
|
rows = append(rows, store.PrefixRow{Prefix: p, CommunityID: comm, Source: cdnSourceKey(src.ID)})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeCDNSourceIntoModuleSnapshot replaces rows for one CDN source in the module snapshot.
|
||||||
|
func mergeCDNSourceIntoModuleSnapshot(st store.Backend, tenantID string, mod *store.Module, sourceID string, newRows []store.PrefixRow) error {
|
||||||
|
if st == nil || mod == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
sourceKey := cdnSourceKey(sourceID)
|
||||||
|
var base []store.PrefixRow
|
||||||
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tenantID, mod.ID); ok && snap != nil {
|
||||||
|
base = mergeSnapshotDropSource(snap.Prefixes, sourceKey)
|
||||||
|
}
|
||||||
|
merged := append(base, newRows...)
|
||||||
|
return persistModuleSnapshot(st, tenantID, mod, merged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCDNBody(body string, src *store.CDNSource) ([]string, error) {
|
||||||
|
pfxs, err := ExtractCIDRs(body, src.SourceKind, src.PrefixPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(pfxs))
|
||||||
|
for _, pfx := range pfxs {
|
||||||
|
out = append(out, pfx.String())
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyCDNSourceHTTPResult(ctx context.Context, st store.Backend, hc *http.Client, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, priorSnapshot []store.PrefixRow, now time.Time) ([]store.PrefixRow, error) {
|
||||||
|
u := strings.TrimSpace(src.URL)
|
||||||
|
if u == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
sourceKey := cdnSourceKey(src.ID)
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
||||||
|
req.Header.Set("If-None-Match", etag)
|
||||||
|
}
|
||||||
|
resp, err := hc.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
|
if cached := prefixRowsForSource(priorSnapshot, sourceKey); len(cached) > 0 {
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("cdn url %s: 304 without cached prefixes", u)
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
prefixStrs, err := parseCDNBody(string(body), src)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
|
||||||
|
}
|
||||||
|
etag := strings.TrimSpace(resp.Header.Get("ETag"))
|
||||||
|
patch := &store.CDNSourcePatch{}
|
||||||
|
if etag != "" && etag != strings.TrimSpace(src.Etag) {
|
||||||
|
e := etag
|
||||||
|
patch.Etag = &e
|
||||||
|
}
|
||||||
|
refreshedAt := now
|
||||||
|
patch.LastRefreshedAt = &refreshedAt
|
||||||
|
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
|
||||||
|
|
||||||
|
rows := cdnRowsFromParsed(mod, src, prefixStrs)
|
||||||
|
if err := mergeCDNSourceIntoModuleSnapshot(st, tenantID, mod, src.ID, rows); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
@@ -3,14 +3,12 @@ package pipeline
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/asnresolve"
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -76,15 +74,11 @@ func collectASPrefixRows(ctx context.Context, st store.Backend, hc *http.Client,
|
|||||||
c := *mod.DefaultCommunityID
|
c := *mod.DefaultCommunityID
|
||||||
comm = &c
|
comm = &c
|
||||||
}
|
}
|
||||||
pfxs, err := asnresolve.AnnouncedPrefixes(ctx, hc, entry.ASN)
|
pfxs, holder, err := resolveASNForEntry(ctx, st, hc, entry.ASN)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results[idx] = entryResult{err: fmt.Errorf("resolve AS%d: %w", entry.ASN, err)}
|
results[idx] = entryResult{err: fmt.Errorf("resolve AS%d: %w", entry.ASN, err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
holder := ""
|
|
||||||
if h, err := asnresolve.ASHolderName(ctx, hc, entry.ASN); err == nil {
|
|
||||||
holder = h
|
|
||||||
}
|
|
||||||
src := fmt.Sprintf("as:%d", entry.ASN)
|
src := fmt.Sprintf("as:%d", entry.ASN)
|
||||||
var rows []store.PrefixRow
|
var rows []store.PrefixRow
|
||||||
for _, pfx := range pfxs {
|
for _, pfx := range pfxs {
|
||||||
@@ -161,18 +155,11 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
u := strings.TrimSpace(src.URL)
|
rows, err := applyCDNSourceHTTPResult(ctx, st, hc, tenantID, moduleID, mod, src, priorSnapshot, now)
|
||||||
if u == "" {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
rows, err := fetchAndParseCDNSource(ctx, hc, st, tenantID, moduleID, mod, src, now)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
results[idx] = srcResult{err: err}
|
results[idx] = srcResult{err: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for i := range rows {
|
|
||||||
rows[i].Source = sourceKey
|
|
||||||
}
|
|
||||||
results[idx] = srcResult{rows: rows}
|
results[idx] = srcResult{rows: rows}
|
||||||
}(i, src)
|
}(i, src)
|
||||||
}
|
}
|
||||||
@@ -188,51 +175,6 @@ func collectCDNPrefixRows(ctx context.Context, st store.Backend, hc *http.Client
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func fetchAndParseCDNSource(ctx context.Context, hc *http.Client, st store.Backend, tenantID, moduleID string, mod *store.Module, src *store.CDNSource, now time.Time) ([]store.PrefixRow, error) {
|
|
||||||
u := strings.TrimSpace(src.URL)
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
resp, err := hc.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cdn fetch %s: %w", u, err)
|
|
||||||
}
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
return nil, fmt.Errorf("cdn url %s: %s", u, resp.Status)
|
|
||||||
}
|
|
||||||
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
|
||||||
_ = resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
etag := strings.TrimSpace(resp.Header.Get("ETag"))
|
|
||||||
patch := &store.CDNSourcePatch{}
|
|
||||||
if etag != "" && etag != strings.TrimSpace(src.Etag) {
|
|
||||||
e := etag
|
|
||||||
patch.Etag = &e
|
|
||||||
}
|
|
||||||
refreshedAt := now
|
|
||||||
patch.LastRefreshedAt = &refreshedAt
|
|
||||||
_, _ = st.UpdateCDNSource(tenantID, moduleID, src.ID, patch)
|
|
||||||
pfxs, err := ExtractCIDRs(string(body), src.SourceKind, src.PrefixPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cdn parse %s: %w", u, err)
|
|
||||||
}
|
|
||||||
var rows []store.PrefixRow
|
|
||||||
for _, pfx := range pfxs {
|
|
||||||
comm := src.CommunityID
|
|
||||||
if comm == nil && mod.DefaultCommunityID != nil {
|
|
||||||
c := *mod.DefaultCommunityID
|
|
||||||
comm = &c
|
|
||||||
}
|
|
||||||
rows = append(rows, store.PrefixRow{Prefix: pfx.String(), CommunityID: comm})
|
|
||||||
}
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profile *store.DohProfile, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
|
func collectDomainPrefixRows(ctx context.Context, hc *http.Client, mod *store.Module, profile *store.DohProfile, entries []*store.DomainEntry) ([]store.PrefixRow, error) {
|
||||||
var validDom []*store.DomainEntry
|
var validDom []*store.DomainEntry
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
package pipeline
|
package pipeline
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -50,45 +47,3 @@ func TestBuildPreviewFragments_SamePrefixDifferentCommunity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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, nil)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PrefetchCDNSourceETags performs conditional GETs for CDN module sources and updates stored ETags when the origin responds 200.
|
// PrefetchCDNSourceETags performs conditional GETs for CDN sources; on 200 parses CIDRs into module_prefix_snapshot.
|
||||||
func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Client) error {
|
func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Client) error {
|
||||||
if hc == nil {
|
if hc == nil {
|
||||||
hc = http.DefaultClient
|
hc = http.DefaultClient
|
||||||
@@ -18,42 +19,70 @@ func PrefetchCDNSourceETags(ctx context.Context, st store.Backend, hc *http.Clie
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
for _, tid := range tenants {
|
for _, tid := range tenants {
|
||||||
for _, mod := range st.ListModules(tid) {
|
for _, mod := range st.ListModules(tid) {
|
||||||
if !mod.Enabled || mod.Type != "CDN_CIDRS" {
|
if mod == nil || !mod.Enabled || mod.Type != "CDN_CIDRS" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
omod, err := st.GetModule(tid, mod.ID)
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
sources, err := st.ListCDNSources(tid, mod.ID)
|
sources, err := st.ListCDNSources(tid, mod.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
var prior []store.PrefixRow
|
||||||
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tid, mod.ID); ok && snap != nil {
|
||||||
|
prior = snap.Prefixes
|
||||||
|
}
|
||||||
for _, src := range sources {
|
for _, src := range sources {
|
||||||
u := strings.TrimSpace(src.URL)
|
if src == nil || strings.TrimSpace(src.URL) == "" {
|
||||||
if u == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimSpace(src.URL), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(src.Etag) != "" {
|
if etag := strings.TrimSpace(src.Etag); etag != "" {
|
||||||
req.Header.Set("If-None-Match", strings.TrimSpace(src.Etag))
|
req.Header.Set("If-None-Match", etag)
|
||||||
}
|
}
|
||||||
resp, err := hc.Do(req)
|
resp, err := hc.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_, _ = io.Copy(io.Discard, resp.Body)
|
if resp.StatusCode == http.StatusNotModified {
|
||||||
_ = resp.Body.Close()
|
_ = resp.Body.Close()
|
||||||
|
continue
|
||||||
|
}
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
etag := strings.TrimSpace(resp.Header.Get("ETag"))
|
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||||
if etag == "" || etag == strings.TrimSpace(src.Etag) {
|
_ = resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
e := etag
|
prefixStrs, err := parseCDNBody(string(body), src)
|
||||||
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, &store.CDNSourcePatch{Etag: &e})
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newEtag := strings.TrimSpace(resp.Header.Get("ETag"))
|
||||||
|
patch := &store.CDNSourcePatch{LastRefreshedAt: &now}
|
||||||
|
if newEtag != "" && newEtag != strings.TrimSpace(src.Etag) {
|
||||||
|
e := newEtag
|
||||||
|
patch.Etag = &e
|
||||||
|
}
|
||||||
|
_, _ = st.UpdateCDNSource(tid, mod.ID, src.ID, patch)
|
||||||
|
rows := cdnRowsFromParsed(omod, src, prefixStrs)
|
||||||
|
_ = mergeCDNSourceIntoModuleSnapshot(st, tid, omod, src.ID, rows)
|
||||||
|
_ = prior // prior may be stale after merge; refresh for next source in loop
|
||||||
|
if snap, ok, _ := st.GetModulePrefixSnapshot(tid, mod.ID); ok && snap != nil {
|
||||||
|
prior = snap.Prefixes
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SchedulerTickSec matches the scheduler ticker interval (internal/scheduler).
|
||||||
|
const SchedulerTickSec = 30
|
||||||
|
|
||||||
|
// ModuleDueForScheduler reports whether a module's refresh interval bucket rolled since the last scheduler tick.
|
||||||
|
func ModuleDueForScheduler(mod *store.Module, now time.Time) bool {
|
||||||
|
if mod == nil || !mod.Enabled || mod.Type == "IP_RANGES" || mod.RefreshIntervalSec <= 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
win := int64(mod.RefreshIntervalSec)
|
||||||
|
if win < 60 {
|
||||||
|
win = 60
|
||||||
|
}
|
||||||
|
cur := now.Unix() / win
|
||||||
|
prev := (now.Unix() - SchedulerTickSec) / win
|
||||||
|
return cur != prev
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package pipeline
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefreshTenantModules ingests all listed modules in parallel and updates per-module snapshots.
|
||||||
|
func RefreshTenantModules(ctx context.Context, st store.Backend, hc *http.Client, tenantID string, moduleIDs []string) error {
|
||||||
|
if hc == nil {
|
||||||
|
hc = http.DefaultClient
|
||||||
|
}
|
||||||
|
var ids []string
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
for _, id := range moduleIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[id]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(ids) == 1 {
|
||||||
|
return RefreshModuleIngest(ctx, st, hc, tenantID, ids[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
sem := make(chan struct{}, collectConcurrency())
|
||||||
|
errs := make([]error, len(ids))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i, mid := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int, moduleID string) {
|
||||||
|
defer wg.Done()
|
||||||
|
sem <- struct{}{}
|
||||||
|
defer func() { <-sem }()
|
||||||
|
errs[idx] = RefreshModuleIngest(ctx, st, hc, tenantID, moduleID)
|
||||||
|
}(i, mid)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for _, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PickTenantRefreshTriggerModule returns a module id for revision metadata (first enabled due module).
|
||||||
|
func PickTenantRefreshTriggerModule(st store.Backend, tenantID string, moduleIDs []string) (string, error) {
|
||||||
|
for _, id := range moduleIDs {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
mod, err := st.GetModule(tenantID, id)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if mod.Enabled {
|
||||||
|
return mod.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, mod := range st.ListModules(tenantID) {
|
||||||
|
if mod != nil && mod.Enabled {
|
||||||
|
return mod.ID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no enabled module for tenant refresh")
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"evobgp/internal/store"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (p *Postgres) GetASNPrefixCache(asn int64) (*store.ASNPrefixCacheEntry, bool, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
var holder string
|
||||||
|
var fetchedAt time.Time
|
||||||
|
var raw []byte
|
||||||
|
err := p.pool.QueryRow(ctx, `
|
||||||
|
SELECT holder, fetched_at, prefixes_json FROM asn_prefix_cache WHERE asn = $1`, asn).
|
||||||
|
Scan(&holder, &fetchedAt, &raw)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var prefixes []string
|
||||||
|
if len(raw) > 0 {
|
||||||
|
_ = json.Unmarshal(raw, &prefixes)
|
||||||
|
}
|
||||||
|
return &store.ASNPrefixCacheEntry{
|
||||||
|
ASN: asn,
|
||||||
|
Holder: holder,
|
||||||
|
Prefixes: prefixes,
|
||||||
|
FetchedAt: fetchedAt.UTC(),
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Postgres) SetASNPrefixCache(asn int64, holder string, prefixes []string) error {
|
||||||
|
raw, err := json.Marshal(prefixes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
_, err = p.pool.Exec(ctx, `
|
||||||
|
INSERT INTO asn_prefix_cache (asn, holder, prefixes_json, fetched_at)
|
||||||
|
VALUES ($1, $2, $3::jsonb, now())
|
||||||
|
ON CONFLICT (asn) DO UPDATE SET
|
||||||
|
holder = EXCLUDED.holder,
|
||||||
|
prefixes_json = EXCLUDED.prefixes_json,
|
||||||
|
fetched_at = EXCLUDED.fetched_at`,
|
||||||
|
asn, holder, string(raw))
|
||||||
|
return err
|
||||||
|
}
|
||||||
+39
-34
@@ -1,8 +1,10 @@
|
|||||||
// Package scheduler drives module refresh intervals and enqueues module_refresh jobs on the shared Registry.
|
// Package scheduler drives module refresh intervals and enqueues tenant_refresh jobs on the shared Registry.
|
||||||
package scheduler
|
package scheduler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -13,6 +15,7 @@ import (
|
|||||||
"evobgp/internal/broker"
|
"evobgp/internal/broker"
|
||||||
"evobgp/internal/config"
|
"evobgp/internal/config"
|
||||||
"evobgp/internal/jobs"
|
"evobgp/internal/jobs"
|
||||||
|
"evobgp/internal/pipeline"
|
||||||
"evobgp/internal/store"
|
"evobgp/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -39,9 +42,9 @@ func Run(ctx context.Context, deps *Deps) {
|
|||||||
t := time.NewTicker(30 * time.Second)
|
t := time.NewTicker(30 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
if deps.Jobs != nil {
|
if deps.Jobs != nil {
|
||||||
log.Printf("evobgp-scheduler: active (in-process enqueue module_refresh)")
|
log.Printf("evobgp-scheduler: active (in-process enqueue tenant_refresh)")
|
||||||
} else {
|
} else {
|
||||||
log.Printf("evobgp-scheduler: active (HTTP POST .../modules/{id}/refresh → %s)", strings.TrimSpace(deps.APIBase))
|
log.Printf("evobgp-scheduler: active (HTTP POST .../tenant/refresh → %s)", strings.TrimSpace(deps.APIBase))
|
||||||
}
|
}
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -60,51 +63,53 @@ func tick(ctx context.Context, deps *Deps) {
|
|||||||
log.Printf("evobgp-scheduler: list tenants: %v", err)
|
log.Printf("evobgp-scheduler: list tenants: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
tickBucket := now.Unix() / pipeline.SchedulerTickSec
|
||||||
for _, tid := range tenants {
|
for _, tid := range tenants {
|
||||||
|
var due []string
|
||||||
for _, mod := range deps.Store.ListModules(tid) {
|
for _, mod := range deps.Store.ListModules(tid) {
|
||||||
if !mod.Enabled || mod.Type == "IP_RANGES" {
|
if pipeline.ModuleDueForScheduler(mod, now) {
|
||||||
|
due = append(due, mod.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(due) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := fmt.Sprintf("sched-tenant-%s-%d", tid, tickBucket)
|
||||||
|
if deps.Jobs != nil {
|
||||||
|
ids := append([]string(nil), due...)
|
||||||
|
_, created, err := deps.Jobs.Enqueue(tid, jobs.KindTenantRefresh, &key, nil, map[string]any{
|
||||||
|
"module_ids": ids,
|
||||||
|
"trigger": "scheduler",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("evobgp-scheduler: enqueue tenant %s: %v", tid, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
interval := mod.RefreshIntervalSec
|
if created {
|
||||||
if interval <= 0 {
|
log.Printf("evobgp-scheduler: queued tenant refresh for %d module(s) in tenant %s", len(due), tid)
|
||||||
continue
|
|
||||||
}
|
|
||||||
win := interval
|
|
||||||
if win < 60 {
|
|
||||||
win = 60
|
|
||||||
}
|
|
||||||
bucket := time.Now().Unix() / int64(win)
|
|
||||||
key := fmt.Sprintf("sched-%s-%d", mod.ID, bucket)
|
|
||||||
if deps.Jobs != nil {
|
|
||||||
mid := mod.ID
|
|
||||||
_, created, err := deps.Jobs.Enqueue(tid, jobs.KindModuleRefresh, &key, &mid, map[string]any{
|
|
||||||
"module_id": mod.ID,
|
|
||||||
"trigger": "scheduler",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("evobgp-scheduler: enqueue module %s: %v", mod.ID, err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if created {
|
|
||||||
log.Printf("evobgp-scheduler: queued refresh for module %s (%s)", mod.ID, mod.Type)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := postModuleRefresh(ctx, deps, mod.ID, key); err != nil {
|
|
||||||
log.Printf("evobgp-scheduler: http refresh module %s: %v", mod.ID, err)
|
|
||||||
}
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := postTenantRefresh(ctx, deps, due, key); err != nil {
|
||||||
|
log.Printf("evobgp-scheduler: http tenant refresh %s: %v", tid, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func postModuleRefresh(ctx context.Context, deps *Deps, moduleID, idempotencyKey string) error {
|
func postTenantRefresh(ctx context.Context, deps *Deps, moduleIDs []string, idempotencyKey string) error {
|
||||||
base := strings.TrimRight(strings.TrimSpace(deps.APIBase), "/")
|
base := strings.TrimRight(strings.TrimSpace(deps.APIBase), "/")
|
||||||
u := base + "/v1/modules/" + moduleID + "/refresh"
|
u := base + "/v1/tenant/refresh"
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
|
body, err := json.Marshal(map[string]any{"module_ids": moduleIDs})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
|
req.Header.Set("Authorization", "Bearer "+strings.TrimSpace(deps.APIToken))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
if idempotencyKey != "" {
|
if idempotencyKey != "" {
|
||||||
req.Header.Set("Idempotency-Key", idempotencyKey)
|
req.Header.Set("Idempotency-Key", idempotencyKey)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,18 @@ type Backend interface {
|
|||||||
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
|
GetModulePrefixSnapshot(tenantID, moduleID string) (*ModulePrefixSnapshot, bool, error)
|
||||||
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
|
SetModulePrefixSnapshot(tenantID, moduleID, inputHash string, prefixes []PrefixRow) error
|
||||||
DeleteModulePrefixSnapshot(tenantID, moduleID string) error
|
DeleteModulePrefixSnapshot(tenantID, moduleID string) error
|
||||||
|
|
||||||
|
// ASNPrefixCache stores RIPEstat announced-prefixes per ASN (global TTL cache).
|
||||||
|
GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error)
|
||||||
|
SetASNPrefixCache(asn int64, holder string, prefixes []string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// ASNPrefixCacheEntry is a cached RIPEstat response for one ASN.
|
||||||
|
type ASNPrefixCacheEntry struct {
|
||||||
|
ASN int64
|
||||||
|
Holder string
|
||||||
|
Prefixes []string
|
||||||
|
FetchedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModulePrefixSnapshot is the cached materialization for one module between refreshes.
|
// ModulePrefixSnapshot is the cached materialization for one module between refreshes.
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ type Memory struct {
|
|||||||
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
settings map[string]map[string]any // tenantID -> key -> JSON-compatible value
|
||||||
revPrefixes map[string][]PrefixRow
|
revPrefixes map[string][]PrefixRow
|
||||||
moduleSnapshots map[string]*moduleSnapshotRec
|
moduleSnapshots map[string]*moduleSnapshotRec
|
||||||
|
asnPrefixCache map[int64]*ASNPrefixCacheEntry
|
||||||
|
|
||||||
// DemoIDs valid after SeedDemo()
|
// DemoIDs valid after SeedDemo()
|
||||||
demoTenantID string
|
demoTenantID string
|
||||||
@@ -128,6 +129,7 @@ func NewMemory() *Memory {
|
|||||||
settings: make(map[string]map[string]any),
|
settings: make(map[string]map[string]any),
|
||||||
revPrefixes: make(map[string][]PrefixRow),
|
revPrefixes: make(map[string][]PrefixRow),
|
||||||
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
moduleSnapshots: make(map[string]*moduleSnapshotRec),
|
||||||
|
asnPrefixCache: make(map[int64]*ASNPrefixCacheEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (m *Memory) GetASNPrefixCache(asn int64) (*ASNPrefixCacheEntry, bool, error) {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
if m.asnPrefixCache == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
e, ok := m.asnPrefixCache[asn]
|
||||||
|
if !ok || e == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
pfx := append([]string(nil), e.Prefixes...)
|
||||||
|
return &ASNPrefixCacheEntry{
|
||||||
|
ASN: asn,
|
||||||
|
Holder: e.Holder,
|
||||||
|
Prefixes: pfx,
|
||||||
|
FetchedAt: e.FetchedAt,
|
||||||
|
}, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Memory) SetASNPrefixCache(asn int64, holder string, prefixes []string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if m.asnPrefixCache == nil {
|
||||||
|
m.asnPrefixCache = make(map[int64]*ASNPrefixCacheEntry)
|
||||||
|
}
|
||||||
|
cp := append([]string(nil), prefixes...)
|
||||||
|
m.asnPrefixCache[asn] = &ASNPrefixCacheEntry{
|
||||||
|
ASN: asn,
|
||||||
|
Holder: holder,
|
||||||
|
Prefixes: cp,
|
||||||
|
FetchedAt: time.Now().UTC(),
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS asn_prefix_cache;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- TTL cache for RIPEstat announced-prefixes per ASN (pipeline ingest).
|
||||||
|
CREATE TABLE asn_prefix_cache (
|
||||||
|
asn BIGINT PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
prefixes_json JSONB NOT NULL DEFAULT '[]',
|
||||||
|
fetched_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_asn_prefix_cache_fetched ON asn_prefix_cache (fetched_at);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE IF EXISTS asn_prefix_cache;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE asn_prefix_cache (
|
||||||
|
asn INTEGER PRIMARY KEY,
|
||||||
|
holder TEXT NOT NULL DEFAULT '',
|
||||||
|
prefixes_json TEXT NOT NULL DEFAULT '[]',
|
||||||
|
fetched_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_asn_prefix_cache_fetched ON asn_prefix_cache (fetched_at);
|
||||||
Reference in New Issue
Block a user