feat(server): hot-reload config store and reloadable infra clients
- 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>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -248,14 +249,164 @@ membership: {}
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user