✨ feat(config): hot-reload Phase 1 — logging.level (ADR-0023)
Implements the first phase of ADR-0023 selective hot-reloading. Adds viper.WatchConfig wiring + an OnConfigChange handler that re-unmarshals the Config struct on file changes and applies the hot-reloadable subset. Phase 1 reloadable field: logging.level — re-applied via SetupLogging on every change. The remaining 3 fields listed in ADR-0023 (api.v2_enabled, telemetry sampler type/ratio, auth.jwt.ttl) follow the same pattern and will land in subsequent phase PRs without further infrastructure work. Changes: - pkg/config/config.go : Config struct gets unexported viper + reloadMu fields; new WatchAndApply(ctx) method starts the watcher and stops it on context cancel. Defensive: no-op when no config file is in use. - pkg/server/server.go Run() : calls WatchAndApply(rootCtx) so the watcher stops on graceful shutdown. - pkg/config/config_hot_reload_test.go (new) : 3 unit tests covering end-to-end reload, no-config-file no-op, nil-viper no-op. Race detector clean. - adr/0023-config-hot-reloading.md : Status → Phase 1 Implemented; remaining fields explicitly Proposed for follow-up phases. Verifier verdict: APPROVE. Race detector passes. Full BDD suite still green. The @flaky scenario in features/config/config_hot_reloading.feature remains @flaky for now — activating it requires reliable cross-process file-watching behaviour which is sensitive to filesystem semantics on the CI runner; the unit test exercises the same code path deterministically.
This commit is contained in:
83
pkg/config/config_hot_reload_test.go
Normal file
83
pkg/config/config_hot_reload_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// loadFromFile is a helper that mimics LoadConfig() for a specific file path
|
||||
// without going through the env-prefix and singleton machinery — keeps the
|
||||
// test hermetic.
|
||||
func loadFromFile(t *testing.T, path string) *Config {
|
||||
t.Helper()
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
v.SetConfigType("yaml")
|
||||
v.SetDefault("logging.level", "info")
|
||||
require.NoError(t, v.ReadInConfig())
|
||||
|
||||
c := &Config{viper: v}
|
||||
require.NoError(t, v.Unmarshal(c))
|
||||
return c
|
||||
}
|
||||
|
||||
// TestWatchAndApply_LoggingLevel proves the hot-reload pipe end-to-end:
|
||||
// write a new logging.level to the watched file, the OnConfigChange handler
|
||||
// re-unmarshals, and the in-memory Config reflects the new value.
|
||||
func TestWatchAndApply_LoggingLevel(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: info\n"), 0644))
|
||||
|
||||
c := loadFromFile(t, path)
|
||||
assert.Equal(t, "info", c.GetLogLevel())
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.WatchAndApply(ctx)
|
||||
|
||||
// Mutate the file. fsnotify needs a real write event; rewrite atomically.
|
||||
require.NoError(t, os.WriteFile(path, []byte("logging:\n level: debug\n"), 0644))
|
||||
|
||||
// Poll for up to 2s waiting for the in-memory level to flip.
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
c.reloadMu.RLock()
|
||||
level := c.GetLogLevel()
|
||||
c.reloadMu.RUnlock()
|
||||
if level == "debug" {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
c.reloadMu.RLock()
|
||||
defer c.reloadMu.RUnlock()
|
||||
t.Fatalf("logging level did not hot-reload to debug: still %q", c.GetLogLevel())
|
||||
}
|
||||
|
||||
// TestWatchAndApply_NoFileNoOp confirms the watcher is a safe no-op when no
|
||||
// config file is in use (env-only / defaults) — important so production
|
||||
// containers without a mounted config.yaml don't crash.
|
||||
func TestWatchAndApply_NoFileNoOp(t *testing.T) {
|
||||
c := &Config{viper: viper.New()}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.WatchAndApply(ctx) // should return without panicking
|
||||
}
|
||||
|
||||
// TestWatchAndApply_NilViperNoOp confirms the watcher tolerates a Config
|
||||
// constructed without the viper field (e.g. tests that build a Config{}
|
||||
// manually — same defensive code path as production but exercised explicitly).
|
||||
func TestWatchAndApply_NilViperNoOp(t *testing.T) {
|
||||
c := &Config{}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
c.WatchAndApply(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user