Очередь job получила lease и fencing, телеметрия и прогресс deploy сохраняются, ревизия пишет manifest входов. Обновление префиксов не переписывает неизменённый снимок, большие наборы идут через COPY, а устаревшая ревизия не попадает в deploy. Бандл ноды кэшируется, prune и постановка job больше не держат горячий путь. Co-authored-by: Cursor <[email protected]>
100 lines
3.1 KiB
Go
100 lines
3.1 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"evobgp/internal/jobs"
|
|
"evobgp/internal/store"
|
|
)
|
|
|
|
type unavailableJobQueue struct{ store.JobQueue }
|
|
|
|
func (q unavailableJobQueue) EnqueueJob(context.Context, store.JobEnqueue) (store.JobRecord, bool, error) {
|
|
return store.JobRecord{}, false, errors.New("database unavailable")
|
|
}
|
|
|
|
func TestJobEnqueueFailureNeverAccepts(t *testing.T) {
|
|
r := jobs.NewRegistry(nil)
|
|
r.UseDurableQueue(unavailableJobQueue{})
|
|
s := &Server{jobs: r}
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/bird/reload", nil)
|
|
req = req.WithContext(context.WithValue(req.Context(), authCtxKey, Auth{TenantID: "tenant", Role: "operator"}))
|
|
w := httptest.NewRecorder()
|
|
s.handleBirdReload(w, req)
|
|
if w.Code == http.StatusAccepted || w.Code < 500 {
|
|
t.Fatalf("DB failure returned %d: %s", w.Code, w.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestAutomaticEnqueueFailureIsVisible(t *testing.T) {
|
|
r := jobs.NewRegistry(nil)
|
|
r.UseDurableQueue(unavailableJobQueue{})
|
|
s := &Server{jobs: r}
|
|
w := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/peers", nil)
|
|
s.enqueuePeerReconcile(w, req, "tenant", "peer_create")
|
|
if got := w.Header().Get("X-EvoBGP-Job-Status"); got != "enqueue-failed" {
|
|
t.Fatalf("scheduling failure hidden: %q", got)
|
|
}
|
|
}
|
|
|
|
type selectorBackend struct {
|
|
store.Backend
|
|
modules []*store.Module
|
|
reads int
|
|
}
|
|
|
|
func (b *selectorBackend) ListModules(string) []*store.Module { b.reads++; return b.modules }
|
|
|
|
func TestTenantRefreshReplayBeforeDynamicSelection(t *testing.T) {
|
|
m := store.NewMemory()
|
|
b := &selectorBackend{Backend: m, modules: []*store.Module{{ID: "module-a", Enabled: true, RefreshIntervalSec: 1}}}
|
|
r := jobs.NewRegistry(nil)
|
|
r.UseDurableQueue(m)
|
|
s := &Server{store: b, jobs: r}
|
|
request := func() *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/tenant/refresh", strings.NewReader(`{}`))
|
|
req.Header.Set("Idempotency-Key", "same-request")
|
|
req = req.WithContext(context.WithValue(req.Context(), authCtxKey, Auth{TenantID: "tenant", Role: "editor"}))
|
|
w := httptest.NewRecorder()
|
|
s.handleTenantRefresh(w, req)
|
|
return w
|
|
}
|
|
first := request()
|
|
if first.Code != http.StatusAccepted {
|
|
t.Fatalf("first=%d %s", first.Code, first.Body.String())
|
|
}
|
|
var accepted map[string]any
|
|
if err := json.Unmarshal(first.Body.Bytes(), &accepted); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claimed, err := m.ClaimJobs(context.Background(), "worker", 1, time.Minute)
|
|
if err != nil || len(claimed) != 1 {
|
|
t.Fatalf("claim %+v %v", claimed, err)
|
|
}
|
|
claimed[0].Status = "succeeded"
|
|
if err = m.FinishJob(context.Background(), claimed[0]); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b.modules = nil
|
|
reads := b.reads
|
|
replay := request()
|
|
if replay.Code != http.StatusAccepted {
|
|
t.Fatalf("replay=%d %s", replay.Code, replay.Body.String())
|
|
}
|
|
var got map[string]any
|
|
if err = json.Unmarshal(replay.Body.Bytes(), &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got["job_id"] != accepted["job_id"] || got["status"] != "succeeded" || b.reads != reads {
|
|
t.Fatalf("replay re-resolved selector: %+v reads %d->%d", got, reads, b.reads)
|
|
}
|
|
}
|