mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-13 04:58:50 +00:00
Add usersFile (newUsersFile/load) for the JSON users-file shape
{"users":[{name,group,apiKey,limits?}]}, and authStore.maybeReload
for throttled hot reload: checks mtime at most every reloadEvery
(5s), reloads on change, keeps the last good set and logs via
errorf on any error (stat, parse, or a rejected replace), and logs
via logf when the reload changes the file-sourced user count.
nowFn is injectable for tests.
Wire-up in llmgateway.go: newGateway does a synchronous initial
load when Users.File is set (a bad initial file fails the
constructor); ServeHTTP calls g.auth.maybeReload() at entry
(no-op when no file is configured).
Also fixes replaceFileUsers's merge semantics: a file user now
overrides an inline user of the same name, even when their keys
differ, dropping the inline entry from the rebuilt set. The prior
digest-only keying let a same-named inline/file pair with
different keys coexist instead of the file version winning.
311 lines
9.3 KiB
Go
311 lines
9.3 KiB
Go
package traefikllmgateway
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// --- test helpers shared with llmgateway_test.go ---
|
|
|
|
// writeUsersDoc marshals users to the {"users":[...]} shape and writes it to
|
|
// fp. When mtime is non-zero, it also forces the file's mtime to that value
|
|
// via os.Chtimes — tests use this to make an mtime change unambiguous
|
|
// regardless of filesystem timestamp resolution, instead of relying on real
|
|
// wall-clock write timing.
|
|
func writeUsersDoc(t *testing.T, fp string, users []*UserConfig, mtime time.Time) {
|
|
t.Helper()
|
|
doc := struct {
|
|
Users []*UserConfig `json:"users"`
|
|
}{Users: users}
|
|
b, err := json.Marshal(doc)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal: %v", err)
|
|
}
|
|
if err := os.WriteFile(fp, b, 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
if !mtime.IsZero() {
|
|
if err := os.Chtimes(fp, mtime, mtime); err != nil {
|
|
t.Fatalf("Chtimes: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// writeUsersFile creates a new users file under dir/name and returns its path.
|
|
func writeUsersFile(t *testing.T, dir, name string, users []*UserConfig) string {
|
|
t.Helper()
|
|
fp := filepath.Join(dir, name)
|
|
writeUsersDoc(t, fp, users, time.Time{})
|
|
return fp
|
|
}
|
|
|
|
// identifyWithKey builds a Bearer-authenticated request for key and runs it
|
|
// through a.identify.
|
|
func identifyWithKey(a *authStore, key string) (*user, *group, bool) {
|
|
r := httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
|
r.Header.Set("Authorization", "Bearer "+key)
|
|
return a.identify(r)
|
|
}
|
|
|
|
// stubLogger is a gatewayLogger that records calls instead of writing to
|
|
// stderr, for tests that exercise maybeReload directly.
|
|
type stubLogger struct {
|
|
infos []string
|
|
errors []string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (s *stubLogger) logf(format string, args ...any) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.infos = append(s.infos, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (s *stubLogger) errorf(format string, args ...any) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.errors = append(s.errors, fmt.Sprintf(format, args...))
|
|
}
|
|
|
|
func (s *stubLogger) infoCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.infos)
|
|
}
|
|
|
|
func (s *stubLogger) errorCount() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.errors)
|
|
}
|
|
|
|
// fakeClock is an injectable, manually-advanced clock for nowFn.
|
|
type fakeClock struct {
|
|
now time.Time
|
|
mu sync.Mutex
|
|
}
|
|
|
|
func (c *fakeClock) Now() time.Time {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
return c.now
|
|
}
|
|
|
|
func (c *fakeClock) Advance(d time.Duration) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
c.now = c.now.Add(d)
|
|
}
|
|
|
|
// newReloadableAuthStore builds an authStore with inline users, performs the
|
|
// same synchronous initial load newGateway would perform against a users
|
|
// file seeded with initialFileUsers, and wires a stub logger plus a fake
|
|
// clock for throttle control.
|
|
func newReloadableAuthStore(t *testing.T, inline, initialFileUsers []*UserConfig) (a *authStore, fp string, log *stubLogger, clock *fakeClock) {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
fp = writeUsersFile(t, dir, "users.json", initialFileUsers)
|
|
|
|
cfg := &Config{
|
|
Providers: map[string]*ProviderConfig{"openai": {Type: "openai", APIKey: "k"}},
|
|
Groups: map[string]*GroupConfig{"eng": {}},
|
|
Users: &UsersConfig{Inline: inline},
|
|
}
|
|
a, err := newAuthStore(cfg)
|
|
if err != nil {
|
|
t.Fatalf("newAuthStore: %v", err)
|
|
}
|
|
|
|
uf := newUsersFile(fp)
|
|
initial, err := uf.load()
|
|
if err != nil {
|
|
t.Fatalf("initial load: %v", err)
|
|
}
|
|
if err := a.replaceFileUsers(initial); err != nil {
|
|
t.Fatalf("initial replaceFileUsers: %v", err)
|
|
}
|
|
|
|
clock = &fakeClock{now: time.Unix(1_700_000_000, 0)}
|
|
log = &stubLogger{}
|
|
a.usersFile = uf
|
|
a.log = log
|
|
a.nowFn = clock.Now
|
|
if info, statErr := os.Stat(fp); statErr == nil {
|
|
a.lastModTime = info.ModTime()
|
|
}
|
|
a.lastCheck = clock.Now()
|
|
|
|
return a, fp, log, clock
|
|
}
|
|
|
|
// --- usersFile.load ---
|
|
|
|
func TestUsersFile_Load_ValidFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fp := writeUsersFile(t, dir, "users.json", []*UserConfig{
|
|
{Name: "alice", Group: "eng", APIKey: "sk-alice", Limits: &LimitsConfig{RequestsPerDay: 100}},
|
|
})
|
|
|
|
uf := newUsersFile(fp)
|
|
users, err := uf.load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if len(users) != 1 {
|
|
t.Fatalf("want 1 user, got %d", len(users))
|
|
}
|
|
got := users[0]
|
|
if got.Name != "alice" || got.Group != "eng" || got.APIKey != "sk-alice" {
|
|
t.Fatalf("unexpected user: %+v", got)
|
|
}
|
|
if got.Limits == nil || got.Limits.RequestsPerDay != 100 {
|
|
t.Fatalf("want limits.requestsPerDay=100, got %+v", got.Limits)
|
|
}
|
|
}
|
|
|
|
func TestUsersFile_Load_MalformedJSON_ReturnsError(t *testing.T) {
|
|
dir := t.TempDir()
|
|
fp := filepath.Join(dir, "bad.json")
|
|
if err := os.WriteFile(fp, []byte("{not json"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
|
|
uf := newUsersFile(fp)
|
|
if _, err := uf.load(); err == nil {
|
|
t.Fatal("want error for malformed JSON")
|
|
}
|
|
}
|
|
|
|
func TestUsersFile_Load_MissingFile_ReturnsError(t *testing.T) {
|
|
uf := newUsersFile(filepath.Join(t.TempDir(), "nope.json"))
|
|
if _, err := uf.load(); err == nil {
|
|
t.Fatal("want error for a missing file")
|
|
}
|
|
}
|
|
|
|
// --- authStore.maybeReload ---
|
|
|
|
func TestAuthStore_MaybeReload_NoFileConfigured_NoOp(t *testing.T) {
|
|
a, err := newAuthStore(testAuthCfg())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a.maybeReload() // must not panic when a.usersFile is nil
|
|
if _, _, ok := identifyWithKey(a, "sk-secret"); !ok {
|
|
t.Fatal("want inline user still identifiable after a no-op maybeReload")
|
|
}
|
|
}
|
|
|
|
func TestAuthStore_MaybeReload_WithinThrottleWindow_DoesNotReload(t *testing.T) {
|
|
a, fp, _, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
|
|
|
|
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "eng", APIKey: "sk-f2"}}, time.Now().Add(time.Hour))
|
|
|
|
clock.Advance(reloadEvery - time.Second)
|
|
a.maybeReload()
|
|
|
|
if _, _, ok := identifyWithKey(a, "sk-f1"); !ok {
|
|
t.Fatal("want f1 still identifiable before the throttle window elapses")
|
|
}
|
|
if _, _, ok := identifyWithKey(a, "sk-f2"); ok {
|
|
t.Fatal("want f2 not yet identifiable before the throttle window elapses")
|
|
}
|
|
}
|
|
|
|
func TestAuthStore_MaybeReload_AfterThrottleWindow_PicksUpMtimeChange(t *testing.T) {
|
|
a, fp, _, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
|
|
|
|
writeUsersDoc(t, fp, []*UserConfig{{Name: "f2", Group: "eng", APIKey: "sk-f2"}}, time.Now().Add(time.Hour))
|
|
|
|
clock.Advance(reloadEvery + time.Second)
|
|
a.maybeReload()
|
|
|
|
if _, _, ok := identifyWithKey(a, "sk-f2"); !ok {
|
|
t.Fatal("want f2 identifiable once the throttle window elapses and mtime changed")
|
|
}
|
|
if _, _, ok := identifyWithKey(a, "sk-f1"); ok {
|
|
t.Fatal("want f1 no longer identifiable once the reload replaced the file-sourced set")
|
|
}
|
|
}
|
|
|
|
func TestAuthStore_MaybeReload_MalformedJSON_KeepsLastGoodSetAndLogsError(t *testing.T) {
|
|
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
|
|
|
|
if err := os.WriteFile(fp, []byte("{not json"), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
future := time.Now().Add(time.Hour)
|
|
if err := os.Chtimes(fp, future, future); err != nil {
|
|
t.Fatalf("Chtimes: %v", err)
|
|
}
|
|
|
|
clock.Advance(reloadEvery + time.Second)
|
|
a.maybeReload()
|
|
|
|
if _, _, ok := identifyWithKey(a, "sk-f1"); !ok {
|
|
t.Fatal("want the last good file-sourced set retained after a malformed reload")
|
|
}
|
|
if log.errorCount() == 0 {
|
|
t.Fatal("want an errorf call recorded for the malformed reload")
|
|
}
|
|
}
|
|
|
|
func TestAuthStore_MaybeReload_UnchangedMtime_DoesNotReload(t *testing.T) {
|
|
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
|
|
_ = fp
|
|
|
|
clock.Advance(reloadEvery + time.Second)
|
|
a.maybeReload()
|
|
|
|
if _, _, ok := identifyWithKey(a, "sk-f1"); !ok {
|
|
t.Fatal("want f1 still identifiable when the file did not change")
|
|
}
|
|
if log.infoCount() != 0 {
|
|
t.Fatal("want no count-change log when the file did not change")
|
|
}
|
|
}
|
|
|
|
func TestAuthStore_MaybeReload_UserCountChange_LogsViaLogf(t *testing.T) {
|
|
a, fp, log, clock := newReloadableAuthStore(t, nil, []*UserConfig{{Name: "f1", Group: "eng", APIKey: "sk-f1"}})
|
|
|
|
writeUsersDoc(t, fp, []*UserConfig{
|
|
{Name: "f1", Group: "eng", APIKey: "sk-f1"},
|
|
{Name: "f2", Group: "eng", APIKey: "sk-f2"},
|
|
}, time.Now().Add(time.Hour))
|
|
|
|
clock.Advance(reloadEvery + time.Second)
|
|
a.maybeReload()
|
|
|
|
if log.infoCount() == 0 {
|
|
t.Fatal("want a logf call recorded for the user-count change (1 -> 2)")
|
|
}
|
|
}
|
|
|
|
// TestAuthStore_MaybeReload_FileUserSameNameAsInline_FileWins is the merge
|
|
// semantic required by Task 4: a file user overrides an inline user of the
|
|
// same name, even when their API keys differ. The inline entry's key must
|
|
// stop working and the file entry's key must resolve to the file's data.
|
|
func TestAuthStore_MaybeReload_FileUserSameNameAsInline_FileWins(t *testing.T) {
|
|
inline := []*UserConfig{{Name: "alice", Group: "eng", APIKey: "sk-inline-alice"}}
|
|
a, fp, _, clock := newReloadableAuthStore(t, inline, nil)
|
|
|
|
writeUsersDoc(t, fp, []*UserConfig{{Name: "alice", Group: "eng", APIKey: "sk-file-alice"}}, time.Now().Add(time.Hour))
|
|
|
|
clock.Advance(reloadEvery + time.Second)
|
|
a.maybeReload()
|
|
|
|
if _, _, ok := identifyWithKey(a, "sk-inline-alice"); ok {
|
|
t.Fatal("want the inline alice overridden once the file defines a same-named user")
|
|
}
|
|
u, _, ok := identifyWithKey(a, "sk-file-alice")
|
|
if !ok || u.name != "alice" {
|
|
t.Fatalf("want file alice identifiable, got %v ok=%v", u, ok)
|
|
}
|
|
}
|