feat(httpapi): harden control plane and speed up prefix refresh

Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DenozordecandCursor committed 2026-11-02 17:23:59 +07:00
1 parent 924f111e48
commit f8e0520817
197 files changed
+18655 -1416

No files matched your search

+43 -18
View File
@@ -5,7 +5,6 @@ import (
"bytes"
"context"
"encoding/json"
"evobgp/internal/logging"
"fmt"
"io"
"log"
@@ -17,18 +16,20 @@ import (
"evobgp/internal/config"
"evobgp/internal/httpclient"
"evobgp/internal/jobs"
"evobgp/internal/logging"
"evobgp/internal/pipeline"
"evobgp/internal/store"
)
// Deps wires the scheduler to the store. Use either Jobs (same process as API, e.g. evobgp-all) or
// APIBase+APIToken to call the control plane over HTTP (separate containers in reference Compose).
// Deps wires the scheduler to a trusted durable Registry (or local evobgp-all),
// or to a single tenant over HTTP using APIBase, APIToken and APITenantID.
type Deps struct {
Store store.Backend
Jobs *jobs.Registry
APIBase string // e.g. http://evobgp-api:8080
APIToken string // Bearer token (operator/editor)
HTTP *http.Client
Store store.Backend
Jobs *jobs.Registry
APIBase string // e.g. http://evobgp-api:8080
APIToken string // Tenant-scoped bearer token (operator/editor)
APITenantID string // Explicit single tenant for HTTP mode; never enumerate other tenants
HTTP *http.Client
}
// Run blocks until ctx is cancelled. Misconfigured deps terminate the process (no idle fallback).
@@ -38,13 +39,13 @@ func Run(ctx context.Context, deps *Deps) {
if deps == nil || deps.Store == nil {
log.Fatalf("evobgp-scheduler: missing store (pass scheduler.Deps with Store from BootstrapWorkers or evobgp-all)")
}
if deps.Jobs == nil && (strings.TrimSpace(deps.APIBase) == "" || strings.TrimSpace(deps.APIToken) == "") {
log.Fatalf("evobgp-scheduler: need either in-process Jobs (evobgp-all) or both EVOBGP_CONTROL_PLANE_URL and EVOBGP_SCHEDULER_BEARER")
if deps.Jobs == nil && (strings.TrimSpace(deps.APIBase) == "" || strings.TrimSpace(deps.APIToken) == "" || strings.TrimSpace(deps.APITenantID) == "") {
log.Fatalf("evobgp-scheduler: need either in-process Jobs (evobgp-all) or EVOBGP_CONTROL_PLANE_URL, EVOBGP_SCHEDULER_BEARER and EVOBGP_SCHEDULER_TENANT_ID")
}
t := time.NewTicker(30 * time.Second)
t := time.NewTicker(time.Duration(pipeline.SchedulerTickSec) * time.Second)
defer t.Stop()
if deps.Jobs != nil {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: active (in-process enqueue tenant_refresh)"))
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: active (registry enqueue tenant_refresh)"))
} else {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: active (HTTP POST .../tenant/refresh → %s)", strings.TrimSpace(deps.APIBase)))
}
@@ -54,20 +55,44 @@ func Run(ctx context.Context, deps *Deps) {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: stopped"))
return
case <-t.C:
tick(context.Background(), deps)
tick(ctx, deps)
}
}
}
func tick(ctx context.Context, deps *Deps) {
tenants, err := deps.Store.ListTenantIDs()
if err != nil {
logging.Default().Info(fmt.Sprintf("evobgp-scheduler: list tenants: %v", err))
tickAt(ctx, deps, time.Now().UTC())
}
func tickAt(ctx context.Context, deps *Deps, now time.Time) {
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
defer cancel()
if ctx.Err() != nil {
return
}
now := time.Now().UTC()
var tenants []string
if deps.Jobs == nil {
tenant := strings.TrimSpace(deps.APITenantID)
if tenant == "" {
return
}
tenants = []string{tenant}
} else {
var err error
tenants, err = deps.Store.ListTenantIDs()
if err != nil {
logging.Default().Error("scheduler list tenants", "err", err)
return
}
}
tickBucket := now.Unix() / pipeline.SchedulerTickSec
for _, tid := range tenants {
if ctx.Err() != nil {
return
}
if tid == store.SystemJobTenantID {
continue
}
var due []string
for _, mod := range deps.Store.ListModules(tid) {
if pipeline.ModuleDueForScheduler(mod, now) {
@@ -80,7 +105,7 @@ func tick(ctx context.Context, deps *Deps) {
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{
_, created, err := deps.Jobs.EnqueueContext(ctx, tid, jobs.KindTenantRefresh, &key, nil, map[string]any{
"module_ids": ids,
"trigger": "scheduler",
})
+114
View File
@@ -0,0 +1,114 @@
package scheduler
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"testing"
"time"
"evobgp/internal/jobs"
"evobgp/internal/pipeline"
"evobgp/internal/store"
)
type schedulerStore struct {
store.Backend
tenants []string
modules map[string][]*store.Module
enumerations int
reads []string
}
func (s *schedulerStore) ListTenantIDs() ([]string, error) { s.enumerations++; return s.tenants, nil }
func (s *schedulerStore) ListModules(tenant string) []*store.Module {
s.reads = append(s.reads, tenant)
return s.modules[tenant]
}
type schedulerRoundTrip func(*http.Request) (*http.Response, error)
func (f schedulerRoundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func schedulerFixture(t *testing.T) (*schedulerStore, time.Time) {
t.Helper()
now := time.Date(2026, 10, 26, 12, 0, 0, 0, time.UTC)
st := &schedulerStore{Backend: store.NewMemory(), tenants: []string{"a", "b", store.SystemJobTenantID}, modules: map[string][]*store.Module{}}
for _, tenant := range st.tenants {
for i := 0; i < 1000; i++ {
mod := &store.Module{ID: fmt.Sprintf("%s-%d", tenant, i), TenantID: tenant, Type: "DOMAINS", Enabled: true, RefreshIntervalSec: 60}
if pipeline.ModuleDueForScheduler(mod, now) {
st.modules[tenant] = []*store.Module{mod}
break
}
}
if len(st.modules[tenant]) == 0 {
t.Fatal("fixture has no due module")
}
}
return st, now
}
func TestSchedulerHTTPExplicitTenantScope(t *testing.T) {
st, now := schedulerFixture(t)
requests := 0
client := &http.Client{Transport: schedulerRoundTrip(func(r *http.Request) (*http.Response, error) {
requests++
var body struct {
ModuleIDs []string `json:"module_ids"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if len(body.ModuleIDs) != 1 || body.ModuleIDs[0] != st.modules["a"][0].ID {
t.Fatalf("cross-tenant payload: %+v", body)
}
if r.Header.Get("Authorization") != "Bearer scoped" {
t.Fatal("missing scoped bearer")
}
if _, ok := r.Context().Deadline(); !ok {
t.Fatal("request has no deadline")
}
return &http.Response{StatusCode: http.StatusAccepted, Body: io.NopCloser(strings.NewReader("{}")), Header: make(http.Header)}, nil
})}
deps := &Deps{Store: st, APIBase: "https://cp.test", APIToken: "scoped", APITenantID: "a", HTTP: client}
tickAt(context.Background(), deps, now)
if requests != 1 || st.enumerations != 0 || len(st.reads) != 1 || st.reads[0] != "a" {
t.Fatalf("scope requests=%d enumerations=%d reads=%v", requests, st.enumerations, st.reads)
}
deps.APITenantID = ""
tickAt(context.Background(), deps, now)
if requests != 1 || len(st.reads) != 1 {
t.Fatal("unscoped HTTP scheduler performed work")
}
}
func TestSchedulerTrustedQueueSeparatesTenantsAndHonorsCancellation(t *testing.T) {
st, now := schedulerFixture(t)
reg := jobs.NewRegistry(nil)
reg.UseDurableQueue(st)
deps := &Deps{Store: st, Jobs: reg}
ctx, cancel := context.WithCancel(context.Background())
cancel()
tickAt(ctx, deps, now)
if st.enumerations != 0 {
t.Fatal("cancelled scheduler accessed store")
}
tickAt(context.Background(), deps, now)
tickAt(context.Background(), deps, now)
for _, tenant := range []string{"a", "b"} {
rows, _, _, err := st.ListJobs(context.Background(), tenant, "", "", "", 10)
if err != nil || len(rows) != 1 || rows[0].TenantID != tenant {
t.Fatalf("queue isolation/idempotency: %s %+v %v", tenant, rows, err)
}
raw, _ := json.Marshal(rows[0].Meta)
if !strings.Contains(string(raw), st.modules[tenant][0].ID) {
t.Fatalf("wrong tenant module: %s", raw)
}
}
for _, read := range st.reads {
if read == store.SystemJobTenantID {
t.Fatal("scheduler read system tenant")
}
}
}