mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-14 05:18:54 +00:00
Micro-fixes from re-review, all text/bound-level, no behavior change to production logic: 1. settleToBaseline's doc comment (config_hot_reload_test.go) claimed to guard the propagation window unconditionally. Reworded: it is a real guard on the fired path (two known rebuilds to wait out), best effort only on the skip path, where no amount of polling can prove a rebuild will never eventually fire — waiting is still required there because the earlier disappear-only check was a guaranteed-zero-wait bug, not because settling can prove safety on that path. 2. Restored the cleanup settle bound from 20s to 30s, matching the longest propagation delay actually observed during verification. 3. README's Config hot-reload intro dropped "on every run" and now states the same fired-vs-skip distinction the test's own Skip message makes: zero non-200s is blip-free-reload evidence only when the watch fires within the test's bound; otherwise it shows just that the stack survived an unreloaded rewrite attempt. 4. auth.go's group.cacheTTL field comment still said a positive value "overrides it for this group's cache entries only" — reworded to the corrected write-time/shared semantics: it sets the TTL written when that group's own request populates an entry, entries are shared across groups, not scoped by group. Gate: go build/vet/gofmt clean on both modules; go test ./... -count=1 green.
390 lines
16 KiB
Go
390 lines
16 KiB
Go
//go:build integration
|
|
|
|
package integration
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// dynamicConfigPath returns the path (relative to this package's directory,
|
|
// also `go test`'s working directory) to the rendered dynamic config
|
|
// Traefik's directory-based file provider watches — the whole directory
|
|
// docker-compose.yml bind-mounts into both traefik1 and traefik2 at
|
|
// /etc/traefik/dynamic (traefik.yml's providers.file.directory), rendered
|
|
// from dynamic.yml.tmpl by `make integration-up`/`integration-keep`.
|
|
func dynamicConfigPath() string {
|
|
return filepath.Join("traefik", "dynamic", "dynamic.yml")
|
|
}
|
|
|
|
// doJSONNonFatal is doJSON's non-fatal counterpart: every error (marshal,
|
|
// request construction, transport, or body read) is returned to the
|
|
// caller instead of calling t.Fatalf. It exists for the two places in this
|
|
// file that poll in a loop — waitForModelAliasBounded and
|
|
// settleToBaseline — where a single transient failure (a request landing
|
|
// mid-rebuild, a dropped connection) must reset that poll's own retry
|
|
// state and try again, never abort the whole test. A non-JSON or empty
|
|
// body decodes to a nil map, matching doJSON's own contract.
|
|
func doJSONNonFatal(method, url, apiKey string, body any) (*http.Response, map[string]any, error) {
|
|
var reader io.Reader
|
|
if body != nil {
|
|
b, err := json.Marshal(body)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("marshal request body: %w", err)
|
|
}
|
|
reader = bytes.NewReader(b)
|
|
}
|
|
|
|
req, err := http.NewRequest(method, url, reader)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("new request %s %s: %w", method, url, err)
|
|
}
|
|
if apiKey != "" {
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
}
|
|
if body != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("do request %s %s: %w", method, url, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
|
|
raw, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return resp, nil, fmt.Errorf("read response body from %s %s: %w", method, url, err)
|
|
}
|
|
var out map[string]any
|
|
if len(raw) > 0 {
|
|
_ = json.Unmarshal(raw, &out)
|
|
}
|
|
return resp, out, nil
|
|
}
|
|
|
|
// modelIDSet extracts the set of model ids from a GET /v1/models response
|
|
// body's "data" array. A body that does not decode as expected yields an
|
|
// empty set, never a panic — callers compare sets, so a malformed body
|
|
// simply fails to match rather than crashing the poll loop it runs inside.
|
|
func modelIDSet(body map[string]any) map[string]bool {
|
|
ids := make(map[string]bool)
|
|
data, ok := body["data"].([]any)
|
|
if !ok {
|
|
return ids
|
|
}
|
|
for _, entry := range data {
|
|
if m, ok := entry.(map[string]any); ok {
|
|
if id, ok := m["id"].(string); ok {
|
|
ids[id] = true
|
|
}
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// sameModelIDSet reports whether a and b contain exactly the same ids.
|
|
func sameModelIDSet(a, b map[string]bool) bool {
|
|
if len(a) != len(b) {
|
|
return false
|
|
}
|
|
for id := range a {
|
|
if !b[id] {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// settleConsecutivePolls is how many consecutive matching polls
|
|
// settleToBaseline requires before declaring the live config settled —
|
|
// one lucky poll landing between two still-in-flight rebuilds is not
|
|
// enough evidence the config has actually stopped changing.
|
|
const settleConsecutivePolls = 3
|
|
|
|
// settleToBaseline polls GET /v1/models (roughly 1s apart) until it
|
|
// observes baseline's exact model id set on settleConsecutivePolls
|
|
// consecutive polls, or bound elapses without ever reaching that streak;
|
|
// it returns whether it settled.
|
|
//
|
|
// TestConfigHotReload's cleanup calls this unconditionally, on both
|
|
// outcomes of the earlier alias poll. On the fired path it is a real
|
|
// guard: two rebuilds are known to have been triggered (the original
|
|
// add, then this cleanup's own restore), and settling confirms both
|
|
// landed before cleanup returns. On the skip path it is best effort
|
|
// only — if the original add's rebuild is still pending past the 30s
|
|
// alias-poll bound, or never fires at all, no amount of polling here can
|
|
// prove that; it can only improve the odds that a still-in-flight
|
|
// rebuild resolves before this test hands control back.
|
|
//
|
|
// Waiting here is still required on the skip path, not a no-op: an
|
|
// earlier version of this cleanup polled only for the injected alias to
|
|
// DISAPPEAR, which trivially and instantly "succeeded" on that path (the
|
|
// alias was never live, so "not present" was already true on the first
|
|
// check) — a guaranteed-zero-wait bug, found when it raced
|
|
// TestUsersFileHotReload's own non-atomic write to users.json during
|
|
// verification: a rebuild this test's own restore triggered landed
|
|
// later, mid-write, read a torn file, and failed plugin construction
|
|
// outright — a global 404 outage until an unrelated later config change
|
|
// triggered a successful rebuild.
|
|
func settleToBaseline(baseURL, apiKey string, baseline map[string]bool, bound time.Duration) bool {
|
|
consecutive := 0
|
|
deadline := time.Now().Add(bound)
|
|
for time.Now().Before(deadline) {
|
|
resp, body, err := doJSONNonFatal(http.MethodGet, baseURL+"/v1/models", apiKey, nil)
|
|
if err == nil && resp.StatusCode == http.StatusOK && sameModelIDSet(modelIDSet(body), baseline) {
|
|
consecutive++
|
|
if consecutive >= settleConsecutivePolls {
|
|
return true
|
|
}
|
|
} else {
|
|
consecutive = 0
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// runChatLoop issues a POST /v1/chat/completions against baseURL for model
|
|
// every interval, from goroutine start until stop is closed, then closes
|
|
// done. Every response whose status is not 200, and every request-level
|
|
// error, is appended to *failures under mu's protection.
|
|
//
|
|
// This never calls any *testing.T method: it runs on its own goroutine, and
|
|
// testing.T.Fatal/FailNow "must be called from the goroutine running the
|
|
// test... not from other goroutines created during the test" (testing
|
|
// package doc) — a violation silently corrupts the test result rather than
|
|
// failing loudly. Every outcome is instead recorded into failures and
|
|
// asserted from the test's own goroutine after the loop stops.
|
|
func runChatLoop(baseURL, apiKey, model string, interval time.Duration, stop <-chan struct{}, done chan<- struct{}, mu *sync.Mutex, failures *[]string) {
|
|
defer close(done)
|
|
|
|
reqBody, err := json.Marshal(map[string]any{
|
|
"model": model,
|
|
"messages": []map[string]any{{"role": "user", "content": "hot-reload-probe"}},
|
|
})
|
|
if err != nil {
|
|
mu.Lock()
|
|
*failures = append(*failures, fmt.Sprintf("marshal probe request body: %v", err))
|
|
mu.Unlock()
|
|
return
|
|
}
|
|
|
|
client := &http.Client{Timeout: 5 * time.Second}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-ticker.C:
|
|
req, err := http.NewRequest(http.MethodPost, baseURL+"/v1/chat/completions", bytes.NewReader(reqBody))
|
|
if err != nil {
|
|
mu.Lock()
|
|
*failures = append(*failures, fmt.Sprintf("build probe request: %v", err))
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+apiKey)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
mu.Lock()
|
|
*failures = append(*failures, fmt.Sprintf("probe request error: %v", err))
|
|
mu.Unlock()
|
|
continue
|
|
}
|
|
_, _ = io.Copy(io.Discard, resp.Body)
|
|
_ = resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
mu.Lock()
|
|
*failures = append(*failures, fmt.Sprintf("probe request: status %d", resp.StatusCode))
|
|
mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForModelAliasBounded polls GET /v1/models on baseURL with apiKey
|
|
// until an entry with id wantID appears, returning true — or false once
|
|
// timeout elapses without ever seeing it. Unlike waitForModelAlias
|
|
// (integration_test.go), a timeout here is not itself a test failure:
|
|
// TestConfigHotReload uses the bool to choose between asserting success and
|
|
// t.Skipf-ing a known, host-dependent limitation (the directory watch not
|
|
// firing within this bound). Uses doJSONNonFatal, not doJSON: a transient
|
|
// transport error during this poll must be treated as "try again", not as
|
|
// an immediate hard failure of the whole test.
|
|
func waitForModelAliasBounded(t *testing.T, baseURL, apiKey, wantID string, timeout time.Duration) bool {
|
|
t.Helper()
|
|
deadline := time.Now().Add(timeout)
|
|
for time.Now().Before(deadline) {
|
|
resp, body, err := doJSONNonFatal(http.MethodGet, baseURL+"/v1/models", apiKey, nil)
|
|
if err == nil && resp.StatusCode == http.StatusOK {
|
|
if data, ok := body["data"].([]any); ok {
|
|
for _, entry := range data {
|
|
if m, ok := entry.(map[string]any); ok && m["id"] == wantID {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
time.Sleep(500 * time.Millisecond)
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestConfigHotReload is item A's proof-and-parity test: Traefik's
|
|
// directory-based file provider (traefik.yml's providers.file.directory,
|
|
// switched from a single-file .filename specifically because a single
|
|
// file's fsnotify watch never fires under Docker Desktop's VirtioFS bind
|
|
// mount) must pick up a config change with no blip in service, and — when
|
|
// it does — the newly added config must actually be live.
|
|
//
|
|
// Every request this test issues — the baseline chat, the sustained probe
|
|
// loop, both /v1/models polls, and the final aliased-model chat — uses
|
|
// hotReloadProbeKey, a dedicated user in its own "hotreload" group with a
|
|
// 100000/min limit (dynamic.yml.tmpl), never aliceKey/"eng". "eng" is
|
|
// shared by most of this suite's other tests and carries a much lower
|
|
// limit (1000/min); this test's own loop alone can burn several hundred
|
|
// requests in one run, and a 429 from that shared budget being exhausted
|
|
// would be indistinguishable, to this test's own "zero non-200s"
|
|
// assertion, from a genuine reload-caused blip.
|
|
//
|
|
// Sequence:
|
|
// 1. Baseline: an existing model answers 200, and the live /v1/models
|
|
// listing is captured as the "baseline" model id set — the value
|
|
// step 6 below polls for once the config is restored.
|
|
// 2. A sustained ~20rps request loop against that same model starts,
|
|
// recording every non-200 (runChatLoop above).
|
|
// 3. While the loop runs, the rendered dynamic config is rewritten to add
|
|
// a new model alias ("aliased/hot": "gpt-mock"), written to a temp
|
|
// file in the same directory then os.Rename'd over the target — an
|
|
// atomic swap mirroring a Kubernetes ConfigMap mount's symlink swap,
|
|
// never an in-place truncate+rewrite a watcher could observe
|
|
// half-written.
|
|
// 4. GET /v1/models is polled (bounded 30s) for "aliased/hot" to appear —
|
|
// this is the directory watch actually firing.
|
|
// 5. The loop stops. The no-blip property — zero non-200s across the
|
|
// entire rewrite/propagation window — is asserted unconditionally,
|
|
// whether or not the watch fired within 30s. On the fired path this is
|
|
// a genuine no-blip-under-reload result; on the skip path it proves
|
|
// only that the stack survived an unreloaded rewrite attempt (see the
|
|
// Skip message below for why that distinction matters).
|
|
// 6. Only if the watch fired: a chat request through the newly live
|
|
// "aliased/hot" alias must succeed (200) — proving the config that
|
|
// actually landed is usable, not just present in the listing.
|
|
//
|
|
// t.Cleanup restores the original rendered config content atomically, the
|
|
// same way step 3 writes it, then unconditionally polls (settleToBaseline)
|
|
// until the live /v1/models listing matches the captured baseline again —
|
|
// see settleToBaseline's own doc comment for the outage this specifically
|
|
// prevents.
|
|
func TestConfigHotReload(t *testing.T) {
|
|
baseReq := map[string]any{
|
|
"model": "openai/gpt-mock",
|
|
"messages": []map[string]any{{"role": "user", "content": "baseline"}},
|
|
}
|
|
resp, body := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", hotReloadProbeKey, baseReq)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("baseline chat: status = %d, body=%#v", resp.StatusCode, body)
|
|
}
|
|
|
|
baselineResp, baselineBody, err := doJSONNonFatal(http.MethodGet, traefik1URL+"/v1/models", hotReloadProbeKey, nil)
|
|
if err != nil || baselineResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("capture baseline model set: status=%v err=%v", baselineResp, err)
|
|
}
|
|
baselineIDs := modelIDSet(baselineBody)
|
|
if len(baselineIDs) == 0 {
|
|
t.Fatalf("baseline /v1/models returned no models, body=%#v; cannot verify settle later", baselineBody)
|
|
}
|
|
|
|
configPath := dynamicConfigPath()
|
|
original, err := os.ReadFile(configPath)
|
|
if err != nil {
|
|
t.Fatalf("read rendered dynamic config %q: %v", configPath, err)
|
|
}
|
|
t.Cleanup(func() {
|
|
tmpPath := configPath + ".restore.tmp"
|
|
if err := os.WriteFile(tmpPath, original, 0o644); err != nil {
|
|
t.Errorf("write restore temp config %q: %v", tmpPath, err)
|
|
return
|
|
}
|
|
if err := os.Rename(tmpPath, configPath); err != nil {
|
|
t.Errorf("rename restore temp config over %q: %v", configPath, err)
|
|
return
|
|
}
|
|
if !settleToBaseline(traefik1URL, hotReloadProbeKey, baselineIDs, 30*time.Second) {
|
|
t.Logf("cleanup: /v1/models did not settle back to the baseline model set within 30s of restoring the config — a later test may race a still-pending rebuild")
|
|
}
|
|
})
|
|
|
|
// Sustained request loop: started before the config mutation below and
|
|
// stopped only after the poll resolves, so it spans the entire
|
|
// rewrite/propagation window, not just the moment of the rename.
|
|
stop := make(chan struct{})
|
|
done := make(chan struct{})
|
|
var mu sync.Mutex
|
|
var failures []string
|
|
go runChatLoop(traefik1URL, hotReloadProbeKey, "openai/gpt-mock", 50*time.Millisecond, stop, done, &mu, &failures)
|
|
|
|
const anchor = `aliased/claude: "claude-mock"`
|
|
if !strings.Contains(string(original), anchor) {
|
|
close(stop)
|
|
<-done
|
|
t.Fatalf("rendered dynamic config does not contain the expected modelAliases anchor %q — dynamic.yml.tmpl may have changed; update this test's injection point", anchor)
|
|
}
|
|
updated := strings.Replace(string(original), anchor, anchor+"\n aliased/hot: \"gpt-mock\"", 1)
|
|
|
|
tmpPath := configPath + ".tmp"
|
|
if err := os.WriteFile(tmpPath, []byte(updated), 0o644); err != nil {
|
|
close(stop)
|
|
<-done
|
|
t.Fatalf("write temp config %q: %v", tmpPath, err)
|
|
}
|
|
if err := os.Rename(tmpPath, configPath); err != nil {
|
|
close(stop)
|
|
<-done
|
|
t.Fatalf("rename %q over %q: %v", tmpPath, configPath, err)
|
|
}
|
|
|
|
fired := waitForModelAliasBounded(t, traefik1URL, hotReloadProbeKey, "aliased/hot", 30*time.Second)
|
|
|
|
close(stop)
|
|
<-done
|
|
|
|
mu.Lock()
|
|
failCount := len(failures)
|
|
sample := append([]string(nil), failures...)
|
|
mu.Unlock()
|
|
if failCount != 0 {
|
|
t.Errorf("sustained request loop saw %d non-200/error result(s) during the config rewrite window, want 0: %v", failCount, sample)
|
|
}
|
|
|
|
if !fired {
|
|
t.Skipf("directory-based file provider watch did not surface the new alias within 30s on this host (a VirtioFS/Docker Desktop fsnotify propagation limitation, not a plugin defect). The zero-failures result above proves only that the stack stayed healthy through an unreloaded rewrite attempt — it is NOT evidence that a successful reload is blip-free, since no reload actually happened on this run")
|
|
}
|
|
|
|
hotReq := map[string]any{
|
|
"model": "aliased/hot",
|
|
"messages": []map[string]any{{"role": "user", "content": "hi"}},
|
|
}
|
|
hotResp, hotBody := doJSON(t, http.MethodPost, traefik1URL+"/v1/chat/completions", hotReloadProbeKey, hotReq)
|
|
if hotResp.StatusCode != http.StatusOK {
|
|
t.Errorf("chat via the newly hot-reloaded alias: status = %d, body=%#v", hotResp.StatusCode, hotBody)
|
|
}
|
|
if content := firstChoiceContent(t, hotBody); content != "mock openai response" {
|
|
t.Errorf("chat via the newly hot-reloaded alias: content = %q, want %q", content, "mock openai response")
|
|
}
|
|
}
|