mirror of
https://github.com/lukaszraczylo/traefik-llmgateway.git
synced 2026-10-12 04:54:45 +00:00
cmd/gateway runs the middleware as its own HTTP server behind Traefik. It reads a YAML config, converts it to JSON and decodes it with DisallowUnknownFields, so a misspelled key stops startup. It warns when metrics.allowedCIDRs is set, because behind a proxy the source address is the proxy's. The Makefile gains build-binary, docker-build and run-local targets. The Dockerfile builds from the committed vendor tree and stamps main.version from the VERSION build argument. go.mod lists go.yaml.in/yaml/v3 as a direct dependency. Claude-Session: https://claude.ai/code/session_01V5jYJNxpDFdJkyyExpADrv
534 lines
19 KiB
Go
534 lines
19 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
yaml "go.yaml.in/yaml/v3"
|
|
|
|
traefikllmgateway "github.com/lukaszraczylo/traefik-llmgateway"
|
|
)
|
|
|
|
// fixturePath is a real production config captured from the live cluster
|
|
// (`kubectl -n traefik get middleware llmgateway -o json`, converted to
|
|
// the ConfigMap-shaped YAML a standalone binary consumes). It carries no
|
|
// key material: every real provider key in it is a `file:` reference
|
|
// resolved at construction from a mounted volume, never a literal.
|
|
//
|
|
// It is checked in rather than read from a scratch directory so `make
|
|
// test` works from a clean clone. testdata/ is export-ignored
|
|
// (.gitattributes), so it never reaches the plugin's release tarball.
|
|
const fixturePath = "testdata/llmgw-config.yaml"
|
|
|
|
// Counts and values below are taken from the fixture itself, not from the
|
|
// struct definitions, and are what the direct-yaml path destroys.
|
|
const (
|
|
fixtureProviders = 18
|
|
fixtureMCPServers = 11
|
|
fixtureModelAliases = 3
|
|
fixtureModelMeta = 21
|
|
fixtureAgents = 34
|
|
fixtureGroups = 3
|
|
fixtureUsersFile = "/llmgw/users/users.json"
|
|
fixtureMetricsCIDR = "192.0.2.0/24"
|
|
)
|
|
|
|
// bothFieldProviders are the providers in the fixture that genuinely
|
|
// carry BOTH apiKey and baseUrl, so both can be asserted non-empty.
|
|
//
|
|
// The set is deliberately NOT "every provider": anthropic has an apiKey
|
|
// but no baseUrl by design (it defaults upstream via
|
|
// defaultBaseURLByType), so a blanket assertion across all 18 would fail
|
|
// for a reason that has nothing to do with the decode being correct.
|
|
var bothFieldProviders = map[string]struct{ apiKey, baseURL string }{
|
|
// G101 false positive: the "file:" prefix is resolveSecret's
|
|
// file-reference form (secrets.go) — a PATH the gateway reads the key
|
|
// out of at construction, not a key. This fixture carries no real
|
|
// credential.
|
|
//nolint:gosec
|
|
"alibaba": {
|
|
apiKey: "file:/llmgw/providers/alibaba",
|
|
baseURL: "https://ws-example.eu-central-1.maas.aliyuncs.com/compatible-mode",
|
|
},
|
|
// G101 false positive, same as "alibaba" above: a resolveSecret file
|
|
// reference, not a credential.
|
|
//nolint:gosec
|
|
"cerebras": {
|
|
apiKey: "file:/llmgw/providers/cerebras",
|
|
baseURL: "https://api.cerebras.ai",
|
|
},
|
|
"codex": {
|
|
apiKey: "unused",
|
|
baseURL: "http://codex-shim.mcp-servers.svc.example.invalid:8080",
|
|
},
|
|
}
|
|
|
|
// TestLoadConfigRoundTripPopulatesCamelCaseFields is the primary
|
|
// regression test for the loader's reason to exist. It asserts the real
|
|
// VALUES the round-trip recovers, not merely that loading returned no
|
|
// error — a nil error is exactly what the broken path also returns.
|
|
func TestLoadConfigRoundTripPopulatesCamelCaseFields(t *testing.T) {
|
|
cfg, err := loadConfig(fixturePath)
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
|
|
if got := len(cfg.Providers); got != fixtureProviders {
|
|
t.Fatalf("providers = %d, want %d", got, fixtureProviders)
|
|
}
|
|
for name, want := range bothFieldProviders {
|
|
pc, ok := cfg.Providers[name]
|
|
if !ok {
|
|
t.Fatalf("provider %q missing from decoded config", name)
|
|
}
|
|
if pc.APIKey != want.apiKey {
|
|
t.Errorf("provider %q apiKey = %q, want %q", name, pc.APIKey, want.apiKey)
|
|
}
|
|
if pc.BaseURL != want.baseURL {
|
|
t.Errorf("provider %q baseUrl = %q, want %q", name, pc.BaseURL, want.baseURL)
|
|
}
|
|
}
|
|
|
|
// anthropic pins the other half of the contract: apiKey present,
|
|
// baseUrl legitimately absent. If a future change "fixes" this by
|
|
// populating a baseUrl, the defaulting behaviour changed.
|
|
anthropic, ok := cfg.Providers["anthropic"]
|
|
if !ok {
|
|
t.Fatal("provider \"anthropic\" missing from decoded config")
|
|
}
|
|
if anthropic.APIKey != "file:/llmgw/providers/anthropic" {
|
|
t.Errorf("anthropic apiKey = %q, want file:/llmgw/providers/anthropic", anthropic.APIKey)
|
|
}
|
|
if anthropic.BaseURL != "" {
|
|
t.Errorf("anthropic baseUrl = %q, want empty (it defaults upstream)", anthropic.BaseURL)
|
|
}
|
|
|
|
// Per-provider camelCase keys beyond apiKey/baseUrl.
|
|
if got := cfg.Providers["gx10"].DiscoveryInterval; got != "5m" {
|
|
t.Errorf("gx10 discoveryInterval = %q, want 5m", got)
|
|
}
|
|
if got := cfg.Providers["gx10"].RequestTimeout; got != "15m" {
|
|
t.Errorf("gx10 requestTimeout = %q, want 15m", got)
|
|
}
|
|
if got := cfg.Providers["macstudio"].MetadataPath; got != "/api/v0/models" {
|
|
t.Errorf("macstudio metadataPath = %q, want /api/v0/models", got)
|
|
}
|
|
|
|
// Blocks the direct-yaml path drops wholesale, because their own
|
|
// top-level keys are camelCase.
|
|
if got := len(cfg.MCPServers); got != fixtureMCPServers {
|
|
t.Errorf("mcpServers = %d, want %d", got, fixtureMCPServers)
|
|
}
|
|
if got := len(cfg.ModelAliases); got != fixtureModelAliases {
|
|
t.Errorf("modelAliases = %d, want %d", got, fixtureModelAliases)
|
|
}
|
|
if got := cfg.ModelAliases["deepseek"]; got != "uni/deepseek-v4-flash-0731" {
|
|
t.Errorf("modelAliases[deepseek] = %q, want uni/deepseek-v4-flash-0731", got)
|
|
}
|
|
if got := len(cfg.ModelMeta); got != fixtureModelMeta {
|
|
t.Errorf("modelMeta = %d, want %d", got, fixtureModelMeta)
|
|
}
|
|
|
|
// metrics.allowedCIDRs decides whether an unauthenticated scrape is
|
|
// allowed at all, so silently emptying it is a security-relevant
|
|
// decode failure, not a cosmetic one.
|
|
if cfg.Metrics == nil {
|
|
t.Fatal("metrics block missing from decoded config")
|
|
}
|
|
if got := cfg.Metrics.AllowedCIDRs; len(got) != 1 || got[0] != fixtureMetricsCIDR {
|
|
t.Errorf("metrics.allowedCIDRs = %v, want [%s]", got, fixtureMetricsCIDR)
|
|
}
|
|
if cfg.Redis == nil || cfg.Redis.FailOpen == nil || !*cfg.Redis.FailOpen {
|
|
t.Errorf("redis.failOpen did not decode to *true (got %+v)", cfg.Redis)
|
|
}
|
|
|
|
// Keys that are already all-lowercase survive BOTH paths. Asserting
|
|
// them here is what makes the direct-path test below meaningful:
|
|
// these are why the broken decode still boots and looks plausible.
|
|
if got := len(cfg.Agents); got != fixtureAgents {
|
|
t.Errorf("agents = %d, want %d", got, fixtureAgents)
|
|
}
|
|
if got := len(cfg.Groups); got != fixtureGroups {
|
|
t.Errorf("groups = %d, want %d", got, fixtureGroups)
|
|
}
|
|
if cfg.Users == nil || cfg.Users.File != fixtureUsersFile {
|
|
t.Errorf("users.file did not decode to %s (got %+v)", fixtureUsersFile, cfg.Users)
|
|
}
|
|
}
|
|
|
|
// TestDirectYAMLUnmarshalSilentlyLosesCamelCaseFields documents WHY
|
|
// loadConfig round-trips through JSON, by proving the obvious-looking
|
|
// alternative fails the exact assertions above while returning a nil
|
|
// error. If yaml tags are ever added to the root package's Config, this
|
|
// test fails and the round-trip can be reconsidered deliberately — which
|
|
// is the point.
|
|
func TestDirectYAMLUnmarshalSilentlyLosesCamelCaseFields(t *testing.T) {
|
|
raw, err := os.ReadFile(fixturePath)
|
|
if err != nil {
|
|
t.Fatalf("ReadFile: %v", err)
|
|
}
|
|
|
|
direct := traefikllmgateway.CreateConfig()
|
|
if err := yaml.Unmarshal(raw, direct); err != nil {
|
|
t.Fatalf("direct yaml.Unmarshal returned an error (%v); this test assumes it fails SILENTLY", err)
|
|
}
|
|
|
|
// The failure is silent, not loud: the structure that makes the
|
|
// gateway boot is all still there.
|
|
if got := len(direct.Providers); got != fixtureProviders {
|
|
t.Fatalf("direct path providers = %d, want %d — the trap is that providers DO survive", got, fixtureProviders)
|
|
}
|
|
|
|
// ...but every camelCase leaf is gone.
|
|
for name := range bothFieldProviders {
|
|
pc, ok := direct.Providers[name]
|
|
if !ok {
|
|
t.Fatalf("direct path: provider %q missing entirely", name)
|
|
}
|
|
if pc.APIKey != "" {
|
|
t.Errorf("direct path: provider %q apiKey = %q, want \"\" — expected the direct decode to lose it", name, pc.APIKey)
|
|
}
|
|
if pc.BaseURL != "" {
|
|
t.Errorf("direct path: provider %q baseUrl = %q, want \"\" — expected the direct decode to lose it", name, pc.BaseURL)
|
|
}
|
|
}
|
|
if len(direct.MCPServers) != 0 {
|
|
t.Errorf("direct path: mcpServers = %d, want 0 (key is camelCase)", len(direct.MCPServers))
|
|
}
|
|
if len(direct.ModelAliases) != 0 {
|
|
t.Errorf("direct path: modelAliases = %d, want 0 (key is camelCase)", len(direct.ModelAliases))
|
|
}
|
|
if len(direct.ModelMeta) != 0 {
|
|
t.Errorf("direct path: modelMeta = %d, want 0 (key is camelCase)", len(direct.ModelMeta))
|
|
}
|
|
if direct.Metrics != nil && len(direct.Metrics.AllowedCIDRs) != 0 {
|
|
t.Errorf("direct path: metrics.allowedCIDRs = %v, want empty (key is camelCase)",
|
|
direct.Metrics.AllowedCIDRs)
|
|
}
|
|
|
|
// The all-lowercase keys survive, which is precisely why the broken
|
|
// config boots rather than failing fast.
|
|
if direct.Users == nil || direct.Users.File != fixtureUsersFile {
|
|
t.Errorf("direct path: users.file = %+v, want %s to survive (key is all-lowercase)", direct.Users, fixtureUsersFile)
|
|
}
|
|
if len(direct.Agents) != fixtureAgents {
|
|
t.Errorf("direct path: agents = %d, want %d to survive (key is all-lowercase)", len(direct.Agents), fixtureAgents)
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigAcceptsJSON checks the .json shortcut decodes to the same
|
|
// Config as the YAML form, since the JSON shape is what kubectl emits.
|
|
func TestLoadConfigAcceptsJSON(t *testing.T) {
|
|
fromYAML, err := loadConfig(fixturePath)
|
|
if err != nil {
|
|
t.Fatalf("loadConfig(yaml): %v", err)
|
|
}
|
|
j, err := json.Marshal(fromYAML)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
jsonPath := filepath.Join(t.TempDir(), "config.json")
|
|
if writeErr := os.WriteFile(jsonPath, j, 0o600); writeErr != nil {
|
|
t.Fatalf("WriteFile: %v", writeErr)
|
|
}
|
|
|
|
fromJSON, err := loadConfig(jsonPath)
|
|
if err != nil {
|
|
t.Fatalf("loadConfig(json): %v", err)
|
|
}
|
|
again, err := json.Marshal(fromJSON)
|
|
if err != nil {
|
|
t.Fatalf("Marshal: %v", err)
|
|
}
|
|
if string(again) != string(j) {
|
|
t.Error("json config path decoded to a different Config than the yaml path")
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigRejectsNonStringMappingKey pins the one round-trip edge
|
|
// case that fails loudly: a mapping key YAML reads as a number cannot
|
|
// become a JSON object key. The wrapped message must name the fix,
|
|
// because the underlying error names only an offset or a map type.
|
|
func TestLoadConfigRejectsNonStringMappingKey(t *testing.T) {
|
|
const doc = `
|
|
providers:
|
|
openai:
|
|
type: openai
|
|
apiKey: k
|
|
modelMeta:
|
|
3.5:
|
|
free: true
|
|
`
|
|
p := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(p, []byte(doc), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
_, err := loadConfig(p)
|
|
if err == nil {
|
|
t.Fatal("loadConfig accepted a numeric mapping key; want an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "quote it") {
|
|
t.Errorf("error does not explain the fix: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigMissingFile checks a bad -config path fails loudly rather
|
|
// than yielding an empty Config that would fail much later with a
|
|
// confusing "at least one provider must be configured".
|
|
func TestLoadConfigMissingFile(t *testing.T) {
|
|
_, err := loadConfig(filepath.Join(t.TempDir(), "nope.yaml"))
|
|
if err == nil {
|
|
t.Fatal("loadConfig accepted a missing file; want an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "read config") {
|
|
t.Errorf("error = %v, want it to name the read failure", err)
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigRejectsUnknownField is the F12 regression test
|
|
// (review-auth.md finding 12): loadConfig's decoder must run with
|
|
// DisallowUnknownFields so a misspelled or nonexistent key fails loudly
|
|
// at startup instead of silently doing nothing — the exact failure class
|
|
// yamlToJSON's own doc comment already documents one real incident of
|
|
// for the direct-yaml path (a whole different bug, same "wrong key,
|
|
// nil error" shape). This fails before the fix (plain json.Unmarshal
|
|
// drops the key silently, err == nil) and passes after.
|
|
func TestLoadConfigRejectsUnknownField(t *testing.T) {
|
|
const doc = `
|
|
providers:
|
|
openai:
|
|
type: openai
|
|
apiKey: k
|
|
notARealConfigKey: true
|
|
`
|
|
p := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(p, []byte(doc), 0o600); err != nil {
|
|
t.Fatalf("WriteFile: %v", err)
|
|
}
|
|
_, err := loadConfig(p)
|
|
if err == nil {
|
|
t.Fatal("loadConfig accepted an unknown top-level key; want an error")
|
|
}
|
|
if !strings.Contains(err.Error(), "notARealConfigKey") {
|
|
t.Errorf("error = %v, want it to name the unknown field", err)
|
|
}
|
|
}
|
|
|
|
// TestLoadConfigStillAcceptsRealFixture is the other half of F12's own
|
|
// regression coverage: the strict decoder must not reject a real,
|
|
// legitimate production config — the exact fixture
|
|
// TestLoadConfigRoundTripPopulatesCamelCaseFields already exercises,
|
|
// re-asserted here as its own named test so a future reviewer sees the
|
|
// negative (rejects unknown) and positive (accepts known) cases side by
|
|
// side.
|
|
func TestLoadConfigStillAcceptsRealFixture(t *testing.T) {
|
|
if _, err := loadConfig(fixturePath); err != nil {
|
|
t.Fatalf("loadConfig(fixturePath) = %v, want the real production fixture to still load under strict decoding", err)
|
|
}
|
|
}
|
|
|
|
// TestMetricsCIDRWarning is the F5 regression test (review-auth.md
|
|
// finding 5): metricsCIDRWarning must warn exactly when
|
|
// Metrics.AllowedCIDRs is non-empty, and stay silent for every other
|
|
// shape (nil Metrics, Metrics with no CIDRs at all) — good/bad/edge,
|
|
// table-driven.
|
|
func TestMetricsCIDRWarning(t *testing.T) {
|
|
cases := []struct {
|
|
cfg *traefikllmgateway.Config
|
|
name string
|
|
want bool
|
|
}{
|
|
{name: "nil metrics block", cfg: &traefikllmgateway.Config{}, want: false},
|
|
{
|
|
name: "metrics enabled, no allowedCIDRs",
|
|
cfg: &traefikllmgateway.Config{Metrics: &traefikllmgateway.MetricsConfig{Enabled: true}},
|
|
want: false,
|
|
},
|
|
{
|
|
name: "allowedCIDRs set",
|
|
cfg: &traefikllmgateway.Config{Metrics: &traefikllmgateway.MetricsConfig{AllowedCIDRs: []string{"10.42.0.0/16"}}},
|
|
want: true,
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
msg, warn := metricsCIDRWarning(tc.cfg)
|
|
if warn != tc.want {
|
|
t.Fatalf("warn = %v, want %v", warn, tc.want)
|
|
}
|
|
if warn && !strings.Contains(msg, "BEHIND Traefik") {
|
|
t.Errorf("message = %q, want it to explain the source address is Traefik's own", msg)
|
|
}
|
|
if !warn && msg != "" {
|
|
t.Errorf("message = %q, want empty when warn is false", msg)
|
|
}
|
|
})
|
|
}
|
|
|
|
t.Run("real fixture triggers the warning", func(t *testing.T) {
|
|
cfg, err := loadConfig(fixturePath)
|
|
if err != nil {
|
|
t.Fatalf("loadConfig: %v", err)
|
|
}
|
|
if _, warn := metricsCIDRWarning(cfg); !warn {
|
|
t.Error("want the fixture's own metrics.allowedCIDRs to trigger the warning")
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestTerminalHandlerEnvelope pins the hand-copied 404 envelope against
|
|
// the shape the root package's writeOAIError produces. writeOAIError is
|
|
// unexported, so this asserts the contract literally: if the root
|
|
// package's envelope ever changes, this is what catches the drift.
|
|
func TestTerminalHandlerEnvelope(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
terminalHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/nope", nil))
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Errorf("status = %d, want 404", rec.Code)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
|
|
t.Errorf("Content-Type = %q, want application/json", ct)
|
|
}
|
|
var got struct {
|
|
Error struct {
|
|
Message string `json:"message"`
|
|
Type string `json:"type"`
|
|
Code string `json:"code"`
|
|
} `json:"error"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("body is not the OpenAI error envelope: %v (%s)", err, rec.Body.String())
|
|
}
|
|
if got.Error.Message != "unknown route" || got.Error.Type != "invalid_request_error" || got.Error.Code != "404" {
|
|
t.Errorf("envelope = %+v, want {unknown route invalid_request_error 404}", got.Error)
|
|
}
|
|
}
|
|
|
|
// TestWithHealth covers both probe paths and, more importantly, that
|
|
// everything else is delegated with the URL untouched.
|
|
func TestWithHealth(t *testing.T) {
|
|
var ready atomic.Bool
|
|
var seenPath, seenEscaped string
|
|
next := http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
|
seenPath = r.URL.Path
|
|
seenEscaped = r.URL.EscapedPath()
|
|
})
|
|
h := withHealth(next, &ready, "/healthz", "/readyz")
|
|
|
|
t.Run("liveness is unconditional", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 even before ready", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("readiness reflects the flag", func(t *testing.T) {
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if rec.Code != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503 while not ready", rec.Code)
|
|
}
|
|
ready.Store(true)
|
|
rec = httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
|
|
if rec.Code != http.StatusOK {
|
|
t.Errorf("status = %d, want 200 once ready", rec.Code)
|
|
}
|
|
})
|
|
|
|
t.Run("a POST to the probe path is delegated", func(t *testing.T) {
|
|
seenPath = ""
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/healthz", nil))
|
|
if seenPath != "/healthz" {
|
|
t.Errorf("POST /healthz was answered by the probe; want it delegated (saw %q)", seenPath)
|
|
}
|
|
})
|
|
|
|
// The reason this wrapper is hand-written instead of an
|
|
// http.ServeMux: a mux would path-clean and 301-redirect these before
|
|
// dispatch, while the gateway routes on the raw EscapedPath and
|
|
// passthrough's traversal checks depend on it arriving unmodified.
|
|
t.Run("paths reach the gateway unnormalized", func(t *testing.T) {
|
|
for _, raw := range []string{"/v1//models", "/openai/a/../b", "/openai/a%2Fb"} {
|
|
seenPath, seenEscaped = "", ""
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, raw, nil))
|
|
if rec.Code == http.StatusMovedPermanently {
|
|
t.Fatalf("%s was redirected; the wrapper must never path-clean", raw)
|
|
}
|
|
if seenEscaped != raw {
|
|
t.Errorf("EscapedPath for %s = %q, want it delegated verbatim (Path was %q)", raw, seenEscaped, seenPath)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// TestParseOptionsPrecedence pins flag > env > default, the house rule
|
|
// that an explicit override always wins.
|
|
func TestParseOptionsPrecedence(t *testing.T) {
|
|
t.Run("default when neither is set", func(t *testing.T) {
|
|
o, err := parseOptions(nil, os.Stderr)
|
|
if err != nil {
|
|
t.Fatalf("parseOptions: %v", err)
|
|
}
|
|
if o.listenAddr != defaultListenAddr {
|
|
t.Errorf("listen = %q, want %q", o.listenAddr, defaultListenAddr)
|
|
}
|
|
if o.configPath != defaultConfigPath {
|
|
t.Errorf("config = %q, want %q", o.configPath, defaultConfigPath)
|
|
}
|
|
})
|
|
|
|
t.Run("env supplies the default", func(t *testing.T) {
|
|
t.Setenv("LLMGW_LISTEN", ":9999")
|
|
t.Setenv("LLMGW_SHUTDOWN_TIMEOUT", "45s")
|
|
o, err := parseOptions(nil, os.Stderr)
|
|
if err != nil {
|
|
t.Fatalf("parseOptions: %v", err)
|
|
}
|
|
if o.listenAddr != ":9999" {
|
|
t.Errorf("listen = %q, want :9999 from env", o.listenAddr)
|
|
}
|
|
if o.shutdownWait.String() != "45s" {
|
|
t.Errorf("shutdown-timeout = %s, want 45s from env", o.shutdownWait)
|
|
}
|
|
})
|
|
|
|
t.Run("an explicit flag beats env", func(t *testing.T) {
|
|
t.Setenv("LLMGW_LISTEN", ":9999")
|
|
o, err := parseOptions([]string{"-listen", ":7777"}, os.Stderr)
|
|
if err != nil {
|
|
t.Fatalf("parseOptions: %v", err)
|
|
}
|
|
if o.listenAddr != ":7777" {
|
|
t.Errorf("listen = %q, want :7777 from the flag", o.listenAddr)
|
|
}
|
|
})
|
|
|
|
t.Run("an unparseable env duration falls back", func(t *testing.T) {
|
|
t.Setenv("LLMGW_DRAIN_DELAY", "soon")
|
|
o, err := parseOptions(nil, os.Stderr)
|
|
if err != nil {
|
|
t.Fatalf("parseOptions: %v", err)
|
|
}
|
|
if o.drainDelay != defaultDrainDelay {
|
|
t.Errorf("drain-delay = %s, want the %s default", o.drainDelay, defaultDrainDelay)
|
|
}
|
|
})
|
|
|
|
t.Run("identical probe paths are rejected", func(t *testing.T) {
|
|
if _, err := parseOptions([]string{"-health-path", "/x", "-ready-path", "/x"}, os.Stderr); err == nil {
|
|
t.Error("parseOptions accepted identical health and ready paths")
|
|
}
|
|
})
|
|
}
|