feat(config): hot-reload Phase 1 — logging.level (ADR-0023) (#42)
Some checks failed
CI/CD Pipeline / Build Docker Cache (push) Successful in 59s
CI/CD Pipeline / CI Pipeline (push) Failing after 4m3s
CI/CD Pipeline / Trigger Docker Push (push) Has been skipped

Co-authored-by: Gabriel Radureau <arcodange@gmail.com>
Co-committed-by: Gabriel Radureau <arcodange@gmail.com>
This commit was merged in pull request #42.
This commit is contained in:
2026-05-05 08:45:19 +02:00
committed by arcodange
parent 03ea2a7b89
commit b33ad236e1
5 changed files with 155 additions and 2 deletions

View File

@@ -1,11 +1,14 @@
package config
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/spf13/viper"
@@ -29,6 +32,15 @@ type Config struct {
Database DatabaseConfig `mapstructure:"database"`
RateLimit RateLimitConfig `mapstructure:"rate_limit"`
Cache CacheConfig `mapstructure:"cache"`
// viper is the underlying configuration source. Kept (unexported,
// mapstructure:"-") so hot-reload can re-unmarshal on file changes —
// see WatchAndApply (ADR-0023 selective hot-reload).
viper *viper.Viper `mapstructure:"-"`
// reloadMu serialises Unmarshal during hot-reload so a partial mutation
// can't be observed mid-flight by getter calls.
reloadMu sync.RWMutex `mapstructure:"-"`
}
// ServerConfig holds server-related configuration
@@ -299,6 +311,9 @@ func LoadConfig() (*Config, error) {
return nil, fmt.Errorf("config unmarshal error: %w", err)
}
// Keep the viper instance for hot-reload (ADR-0023).
config.viper = v
// Setup logging based on configuration (level, output file, time format).
// The JSON/console format was already applied at the top of LoadConfig via
// peekJSONLogging, so SetupLogging only needs to handle the remaining knobs.
@@ -590,3 +605,53 @@ func (c *Config) setupLogOutput() {
log.Logger = log.Output(file)
log.Trace().Str("output", output).Msg("Logging to file")
}
// WatchAndApply starts watching the config file for changes and applies the
// hot-reloadable subset on every change (ADR-0023 selective hot-reload).
//
// Phase 1 (this PR) reloads:
// - logging.level — re-applies via SetupLogging on every change.
//
// The other fields listed in ADR-0023 (api.v2_enabled, telemetry sampler,
// auth.jwt.ttl) remain restart-only until their handlers land in a follow-up.
//
// Stops when ctx is cancelled. Safe to call once at server startup.
// If the config file is absent (ConfigFileNotFoundError at load time), this
// becomes a no-op and logs a single warning.
func (c *Config) WatchAndApply(ctx context.Context) {
if c.viper == nil {
log.Warn().Msg("Config hot-reload disabled: no viper instance attached")
return
}
if c.viper.ConfigFileUsed() == "" {
log.Info().Msg("Config hot-reload disabled: no config file in use (env-only or defaults)")
return
}
c.viper.OnConfigChange(func(in fsnotify.Event) {
log.Info().Str("event", in.Op.String()).Str("file", in.Name).Msg("Config file changed, reloading hot-reloadable fields")
c.reloadMu.Lock()
defer c.reloadMu.Unlock()
if err := c.viper.Unmarshal(c); err != nil {
log.Error().Err(err).Msg("Hot-reload: failed to unmarshal new config, keeping previous values")
return
}
// Apply hot-reloadable fields. Order matters: logging first so the
// rest of the reload is logged at the right level.
c.SetupLogging()
log.Info().Str("logging_level", c.GetLogLevel()).Msg("Hot-reload applied (logging.level)")
})
c.viper.WatchConfig()
log.Info().Str("file", c.viper.ConfigFileUsed()).Msg("Config hot-reload watcher started (ADR-0023 Phase 1)")
// Stop the watcher on context cancel — viper has no public Stop method,
// so we just clear the callback to make further events no-ops.
go func() {
<-ctx.Done()
c.viper.OnConfigChange(func(_ fsnotify.Event) {})
log.Info().Msg("Config hot-reload watcher stopped")
}()
}