618399f86d
- Replace single Load() with a watching Store that re-reads config files
(and config.local.yaml) and fans out a ReloadEvent with a per-field diff
so consumers can decide whether the change is hot-applicable or requires
a process restart.
- Wrap llm, retrieval, vector store, and object storage clients in
Reloadable* shells so the bootstrap can swap their underlying impls when
config changes without re-instantiating handlers.
- Make jwt.Manager and ops TokenIssuer mutable under a lock so secrets and
TTLs can be rotated live; thread default plan code through a setter on
the ops AdminUserService.
- Wire ConfigStore through bootstrap and every cmd/main.go, scheduler /
worker / tenant-api / ops-api start the watcher; services and handlers
take a config.Provider so they always read current values for things
like generation.stream_enabled, scheduler dispatch, retrieval, etc.
- Switch shared/config decoding off viper to a Kratos-derived runtime
package so env placeholders (\${VAR:default}) resolve consistently and
the same source machinery powers both the loader and the watcher.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
413 lines
11 KiB
Go
413 lines
11 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLoadPrefersEnvLLMAPIKey(t *testing.T) {
|
|
t.Setenv("LLM_API_KEY", "env-priority-key")
|
|
|
|
configPath := writeTestConfig(t, `
|
|
llm:
|
|
provider: ark
|
|
api_key: "config-file-key"
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.LLM.APIKey != "env-priority-key" {
|
|
t.Fatalf("expected env api key, got %q", cfg.LLM.APIKey)
|
|
}
|
|
}
|
|
|
|
func TestLoadFallsBackToConfigLLMAPIKey(t *testing.T) {
|
|
t.Setenv("LLM_API_KEY", "")
|
|
|
|
configPath := writeTestConfig(t, `
|
|
llm:
|
|
provider: ark
|
|
api_key: "config-file-key"
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.LLM.APIKey != "config-file-key" {
|
|
t.Fatalf("expected config api key, got %q", cfg.LLM.APIKey)
|
|
}
|
|
}
|
|
|
|
func TestLoadPrefersEnvRetrievalAndQdrantSettings(t *testing.T) {
|
|
t.Setenv("SILICONFLOW_API_KEY", "siliconflow-env-key")
|
|
t.Setenv("SILICONFLOW_EMBEDDING_MODEL", "env-embedding")
|
|
t.Setenv("SILICONFLOW_RERANKER_MODEL", "env-reranker")
|
|
t.Setenv("QDRANT_API_KEY", "qdrant-env-key")
|
|
t.Setenv("QDRANT_URL", "qdrant-env:6334")
|
|
t.Setenv("QDRANT_COLLECTION", "env_collection")
|
|
|
|
configPath := writeTestConfig(t, `
|
|
qdrant:
|
|
url: qdrant-config:6334
|
|
api_key: "qdrant-config-key"
|
|
collection: "config_collection"
|
|
retrieval:
|
|
provider: siliconflow
|
|
api_key: "retrieval-config-key"
|
|
embedding_model: "config-embedding"
|
|
reranker_model: "config-reranker"
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.Retrieval.APIKey != "siliconflow-env-key" {
|
|
t.Fatalf("expected env retrieval api key, got %q", cfg.Retrieval.APIKey)
|
|
}
|
|
if cfg.Retrieval.EmbeddingModel != "env-embedding" {
|
|
t.Fatalf("expected env embedding model, got %q", cfg.Retrieval.EmbeddingModel)
|
|
}
|
|
if cfg.Retrieval.RerankerModel != "env-reranker" {
|
|
t.Fatalf("expected env reranker model, got %q", cfg.Retrieval.RerankerModel)
|
|
}
|
|
if cfg.Qdrant.APIKey != "qdrant-env-key" {
|
|
t.Fatalf("expected env qdrant api key, got %q", cfg.Qdrant.APIKey)
|
|
}
|
|
if cfg.Qdrant.URL != "qdrant-env:6334" {
|
|
t.Fatalf("expected env qdrant url, got %q", cfg.Qdrant.URL)
|
|
}
|
|
if cfg.Qdrant.Collection != "env_collection" {
|
|
t.Fatalf("expected env qdrant collection, got %q", cfg.Qdrant.Collection)
|
|
}
|
|
}
|
|
|
|
func TestLoadAppliesBrandLibraryDefaults(t *testing.T) {
|
|
configPath := writeTestConfig(t, `
|
|
brand_library: {}
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.BrandLibrary.FreeBrandLimit != 1 {
|
|
t.Fatalf("expected default free brand limit 1, got %d", cfg.BrandLibrary.FreeBrandLimit)
|
|
}
|
|
if cfg.BrandLibrary.PaidBrandLimit != 2 {
|
|
t.Fatalf("expected default paid brand limit 2, got %d", cfg.BrandLibrary.PaidBrandLimit)
|
|
}
|
|
if cfg.BrandLibrary.MaxKeywords != 5 {
|
|
t.Fatalf("expected default max keywords 5, got %d", cfg.BrandLibrary.MaxKeywords)
|
|
}
|
|
if cfg.BrandLibrary.MaxQuestionsPerKeyword != 5 {
|
|
t.Fatalf("expected default max questions per keyword 5, got %d", cfg.BrandLibrary.MaxQuestionsPerKeyword)
|
|
}
|
|
}
|
|
|
|
func TestLoadAppliesSchedulerHTTPDefaultsAndDisableSentinel(t *testing.T) {
|
|
defaultConfigPath := writeTestConfig(t, `
|
|
scheduler: {}
|
|
`)
|
|
|
|
cfg, err := Load(defaultConfigPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() default error = %v", err)
|
|
}
|
|
if cfg.Scheduler.HTTPHost != "127.0.0.1" {
|
|
t.Fatalf("expected default scheduler http host 127.0.0.1, got %q", cfg.Scheduler.HTTPHost)
|
|
}
|
|
if cfg.Scheduler.HTTPPort != 8081 {
|
|
t.Fatalf("expected default scheduler http port 8081, got %d", cfg.Scheduler.HTTPPort)
|
|
}
|
|
|
|
disabledConfigPath := writeTestConfig(t, `
|
|
scheduler:
|
|
http_port: -1
|
|
`)
|
|
|
|
cfg, err = Load(disabledConfigPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() disabled error = %v", err)
|
|
}
|
|
if cfg.Scheduler.HTTPPort != -1 {
|
|
t.Fatalf("expected scheduler http port -1 to disable server, got %d", cfg.Scheduler.HTTPPort)
|
|
}
|
|
}
|
|
|
|
func TestLoadPrefersEnvSchedulerMetricsSettings(t *testing.T) {
|
|
t.Setenv("SCHEDULER_HTTP_HOST", "0.0.0.0")
|
|
t.Setenv("SCHEDULER_INTERNAL_METRICS_TOKEN", "env-metrics-token")
|
|
|
|
configPath := writeTestConfig(t, `
|
|
scheduler:
|
|
http_host: 127.0.0.1
|
|
internal_metrics_token: config-token
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
if cfg.Scheduler.HTTPHost != "0.0.0.0" {
|
|
t.Fatalf("expected env scheduler http host, got %q", cfg.Scheduler.HTTPHost)
|
|
}
|
|
if cfg.Scheduler.InternalMetricsToken != "env-metrics-token" {
|
|
t.Fatalf("expected env scheduler metrics token, got %q", cfg.Scheduler.InternalMetricsToken)
|
|
}
|
|
}
|
|
|
|
func TestLoadPrefersEnvJWTSettings(t *testing.T) {
|
|
t.Setenv("JWT_SECRET", "env-jwt-secret")
|
|
t.Setenv("JWT_ACCESS_TTL", "20m")
|
|
t.Setenv("JWT_REFRESH_TTL", "720h")
|
|
|
|
configPath := writeTestConfig(t, `
|
|
jwt:
|
|
secret: config-jwt-secret
|
|
access_ttl: 15m
|
|
refresh_ttl: 168h
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
if cfg.JWT.Secret != "env-jwt-secret" {
|
|
t.Fatalf("expected env jwt secret, got %q", cfg.JWT.Secret)
|
|
}
|
|
if cfg.JWT.AccessTTL != 20*time.Minute {
|
|
t.Fatalf("expected env jwt access ttl 20m, got %s", cfg.JWT.AccessTTL)
|
|
}
|
|
if cfg.JWT.RefreshTTL != 720*time.Hour {
|
|
t.Fatalf("expected env jwt refresh ttl 720h, got %s", cfg.JWT.RefreshTTL)
|
|
}
|
|
}
|
|
|
|
func TestLoadAppliesMembershipDefaults(t *testing.T) {
|
|
configPath := writeTestConfig(t, `
|
|
membership: {}
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
|
|
if cfg.Membership.DefaultPlanCode != "free" {
|
|
t.Fatalf("expected default plan code free, got %q", cfg.Membership.DefaultPlanCode)
|
|
}
|
|
if len(cfg.Membership.Plans) != 3 {
|
|
t.Fatalf("expected 3 default plans, got %d", len(cfg.Membership.Plans))
|
|
}
|
|
|
|
free, ok := cfg.Membership.FindPlan("free")
|
|
if !ok {
|
|
t.Fatalf("expected free plan to exist")
|
|
}
|
|
if free.ArticleGeneration != 3 {
|
|
t.Fatalf("expected free article generation 3, got %d", free.ArticleGeneration)
|
|
}
|
|
if free.ImageStorageBytes != 10*1024*1024 {
|
|
t.Fatalf("expected free image storage 10MB, got %d", free.ImageStorageBytes)
|
|
}
|
|
if free.CompanyLimit != 1 {
|
|
t.Fatalf("expected free company limit 1, got %d", free.CompanyLimit)
|
|
}
|
|
if free.SubscriptionDuration != 72*time.Hour {
|
|
t.Fatalf("expected free subscription duration 72h, got %s", free.SubscriptionDuration)
|
|
}
|
|
if !free.ContactAdminOnExpiry {
|
|
t.Fatalf("expected free plan to require admin contact on expiry")
|
|
}
|
|
|
|
pro, ok := cfg.Membership.FindPlan("pro")
|
|
if !ok {
|
|
t.Fatalf("expected pro plan to exist")
|
|
}
|
|
if pro.ArticleGeneration != 400 {
|
|
t.Fatalf("expected pro article generation 400, got %d", pro.ArticleGeneration)
|
|
}
|
|
if pro.AIPointsMonthly != 1500 {
|
|
t.Fatalf("expected pro ai points 1500, got %d", pro.AIPointsMonthly)
|
|
}
|
|
if pro.AIPointBaseChars != 1000 {
|
|
t.Fatalf("expected pro ai point base chars 1000, got %d", pro.AIPointBaseChars)
|
|
}
|
|
if pro.CompanyLimit != 2 {
|
|
t.Fatalf("expected pro company limit 2, got %d", pro.CompanyLimit)
|
|
}
|
|
}
|
|
|
|
func TestLoadMergesLocalOverrideAndResolvesEnvPlaceholders(t *testing.T) {
|
|
t.Setenv("CONFIG_TEST_LLM_KEY", "placeholder-key")
|
|
t.Setenv("LLM_API_KEY", "")
|
|
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.yaml")
|
|
localPath := filepath.Join(dir, "config.local.yaml")
|
|
writeFile(t, configPath, `
|
|
llm:
|
|
provider: ark
|
|
api_key: "${CONFIG_TEST_LLM_KEY:missing}"
|
|
max_output_tokens: 1000
|
|
generation:
|
|
stream_enabled: false
|
|
`)
|
|
writeFile(t, localPath, `
|
|
generation:
|
|
stream_enabled: true
|
|
`)
|
|
|
|
cfg, err := Load(configPath)
|
|
if err != nil {
|
|
t.Fatalf("Load() error = %v", err)
|
|
}
|
|
if cfg.LLM.APIKey != "placeholder-key" {
|
|
t.Fatalf("expected placeholder env api key, got %q", cfg.LLM.APIKey)
|
|
}
|
|
if !cfg.Generation.StreamEnabled {
|
|
t.Fatalf("expected local override to enable generation stream")
|
|
}
|
|
}
|
|
|
|
func TestStoreReloadsConfigAndKeepsPreviousOnInvalidYAML(t *testing.T) {
|
|
configPath := writeTestConfig(t, `
|
|
jwt:
|
|
secret: first
|
|
access_ttl: 15m
|
|
refresh_ttl: 720h
|
|
`)
|
|
|
|
store, err := NewStore(configPath)
|
|
if err != nil {
|
|
t.Fatalf("NewStore() error = %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
events := make(chan ReloadEvent, 4)
|
|
go func() {
|
|
_ = store.Watch(ctx, nil, func(event ReloadEvent) {
|
|
events <- event
|
|
})
|
|
}()
|
|
|
|
waitForStoreWatch(t, store)
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
writeFile(t, configPath, `
|
|
jwt:
|
|
secret: second
|
|
access_ttl: 20m
|
|
refresh_ttl: 720h
|
|
`)
|
|
event := waitForReloadEvent(t, events)
|
|
if event.Current.JWT.Secret != "second" {
|
|
t.Fatalf("expected reloaded jwt secret second, got %q", event.Current.JWT.Secret)
|
|
}
|
|
if store.Current().JWT.AccessTTL != 20*time.Minute {
|
|
t.Fatalf("expected store access ttl 20m, got %s", store.Current().JWT.AccessTTL)
|
|
}
|
|
|
|
writeFile(t, configPath, "jwt:\n secret: [broken\n")
|
|
time.Sleep(300 * time.Millisecond)
|
|
if store.Current().JWT.Secret != "second" {
|
|
t.Fatalf("expected invalid yaml to keep previous config, got %q", store.Current().JWT.Secret)
|
|
}
|
|
}
|
|
|
|
func TestStoreReloadsWhenLocalOverrideIsCreated(t *testing.T) {
|
|
dir := t.TempDir()
|
|
configPath := filepath.Join(dir, "config.yaml")
|
|
localPath := filepath.Join(dir, "config.local.yaml")
|
|
writeFile(t, configPath, `
|
|
generation:
|
|
stream_enabled: false
|
|
`)
|
|
|
|
store, err := NewStore(configPath)
|
|
if err != nil {
|
|
t.Fatalf("NewStore() error = %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
events := make(chan ReloadEvent, 4)
|
|
go func() {
|
|
_ = store.Watch(ctx, nil, func(event ReloadEvent) {
|
|
events <- event
|
|
})
|
|
}()
|
|
waitForStoreWatch(t, store)
|
|
time.Sleep(100 * time.Millisecond)
|
|
|
|
writeFile(t, localPath, `
|
|
generation:
|
|
stream_enabled: true
|
|
`)
|
|
|
|
event := waitForReloadEvent(t, events)
|
|
if !event.Current.Generation.StreamEnabled {
|
|
t.Fatalf("expected created local override to enable stream")
|
|
}
|
|
}
|
|
|
|
func writeTestConfig(t *testing.T, body string) string {
|
|
t.Helper()
|
|
|
|
dir := t.TempDir()
|
|
path := filepath.Join(dir, "config.yaml")
|
|
writeFile(t, path, body)
|
|
|
|
return path
|
|
}
|
|
|
|
func writeFile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatalf("write file %s: %v", path, err)
|
|
}
|
|
}
|
|
|
|
func waitForStoreWatch(t *testing.T, store *Store) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(2 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if store.runtime != nil && store.runtime.Value("").Load() != nil {
|
|
return
|
|
}
|
|
time.Sleep(10 * time.Millisecond)
|
|
}
|
|
t.Fatalf("config store watcher did not start")
|
|
}
|
|
|
|
func waitForReloadEvent(t *testing.T, events <-chan ReloadEvent) ReloadEvent {
|
|
t.Helper()
|
|
timeout := time.After(3 * time.Second)
|
|
for {
|
|
select {
|
|
case event := <-events:
|
|
if event.Current != nil {
|
|
return event
|
|
}
|
|
case <-timeout:
|
|
t.Fatalf("timed out waiting for reload event")
|
|
}
|
|
}
|
|
}
|