package config import ( "errors" "fmt" "os" "path/filepath" "slices" "strings" "sync" "sync/atomic" "time" "github.com/fsnotify/fsnotify" "github.com/go-viper/mapstructure/v2" "github.com/spf13/viper" ) var ErrConfigPathRequired = errors.New("configuration path is required") // Store owns an immutable configuration snapshot and its Viper-backed file // watcher. It intentionally remains separate from integration/runtimeconfig.Store: // this store replaces the whole file-backed application snapshot, while the // integration store publishes database-backed provider changes by key. // Every accessor returns a deep copy, so readers can safely use it without // coordinating with a reload in another goroutine. type Store struct { snapshot atomic.Pointer[Config] path string mu sync.RWMutex listeners map[uint64]func(*Config) nextID uint64 watcher *fsnotify.Watcher stop chan struct{} done chan struct{} } // Load reads a YAML/JSON/TOML configuration file or a directory containing // config.yaml. Environment variables with KRA_ prefix override file values; // nested keys use underscores (for example KRA_ADMIN_JWT_SIGNING_KEY). func Load(path string) (*Config, error) { resolved, err := resolvePath(path) if err != nil { return nil, err } v := newViper(resolved) if err = v.ReadInConfig(); err != nil { return nil, fmt.Errorf("read configuration %q: %w", resolved, err) } config, err := decode(v) if err != nil { return nil, fmt.Errorf("decode configuration %q: %w", resolved, err) } setConfigPath(config, resolved) return config, nil } // NewStore creates a store from an already decoded snapshot. The input is // copied immediately and may be reused or mutated by the caller afterwards. func NewStore(config *Config) *Store { store := &Store{listeners: make(map[uint64]func(*Config))} if config != nil && config.Admin != nil { store.path = config.Admin.ConfigPath } store.Replace(config) return store } // LoadStore loads the initial snapshot and starts the single process-wide // Viper-backed watcher. func LoadStore(path string) (*Store, error) { config, err := Load(path) if err != nil { return nil, err } store := NewStore(config) if err = store.Watch(); err != nil { store.Close() return nil, err } return store, nil } func (r *Store) Snapshot() *Config { if r == nil { return nil } return cloneConfig(r.snapshot.Load()) } func (r *Store) Server() *Server { config := r.Snapshot() if config == nil { return nil } return config.Server } func (r *Store) Data() *Data { config := r.Snapshot() if config == nil { return nil } return config.Data } func (r *Store) Admin() *Admin { config := r.Snapshot() if config == nil { return nil } return config.Admin } func (r *Store) Values() (*Data, *Admin) { config := r.Snapshot() if config == nil { return nil, nil } return config.Data, config.Admin } func (r *Store) UpdateDatabase(database *Database) { if r == nil { return } config := r.Snapshot() if config == nil { config = &Config{} } if config.Data == nil { config.Data = &Data{} } config.Data.Database = clonePtr(database) r.replace(config, false) } func (r *Store) ConfigPath() string { if r == nil { return "" } r.mu.RLock() path := r.path r.mu.RUnlock() return path } func (r *Store) Replace(config *Config) { r.replace(config, true) } func (r *Store) replace(config *Config, notify bool) { if r == nil { return } next := cloneConfig(config) if next == nil { next = &Config{} } if path := r.ConfigPath(); path != "" { setConfigPath(next, path) } r.snapshot.Store(next) if !notify { return } r.mu.RLock() listeners := make([]func(*Config), 0, len(r.listeners)) for _, listener := range r.listeners { listeners = append(listeners, listener) } r.mu.RUnlock() for _, listener := range listeners { listener(cloneConfig(next)) } } // Subscribe registers a callback after each successful replacement. The // callback is invoked outside the runtime lock, and the returned function is // idempotent so cleanup can safely be deferred by multiple owners. func (r *Store) Subscribe(listener func(*Config)) func() { if r == nil || listener == nil { return func() {} } r.mu.Lock() r.nextID++ id := r.nextID r.listeners[id] = listener r.mu.Unlock() var once sync.Once return func() { once.Do(func() { r.mu.Lock() delete(r.listeners, id) r.mu.Unlock() }) } } // Watch starts watching the source directory. Repeated calls are harmless. func (r *Store) Watch() error { if r == nil { return ErrConfigPathRequired } path := r.ConfigPath() if path == "" { return ErrConfigPathRequired } r.mu.Lock() if r.watcher != nil { r.mu.Unlock() return nil } watcher, err := fsnotify.NewWatcher() if err != nil { r.mu.Unlock() return fmt.Errorf("create configuration watcher: %w", err) } if err = watcher.Add(filepath.Dir(path)); err != nil { _ = watcher.Close() r.mu.Unlock() return fmt.Errorf("watch configuration directory: %w", err) } r.watcher = watcher r.stop = make(chan struct{}) r.done = make(chan struct{}) stop, done := r.stop, r.done r.mu.Unlock() go r.watchLoop(watcher, stop, done, path) return nil } func (r *Store) watchLoop(watcher *fsnotify.Watcher, stop <-chan struct{}, done chan<- struct{}, path string) { defer close(done) const debounce = 100 * time.Millisecond var timer *time.Timer var timerC <-chan time.Time events := watcher.Events errors := watcher.Errors for { select { case event, ok := <-events: if !ok { events = nil if errors == nil { return } continue } if filepath.Clean(event.Name) != path || event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 { continue } if timer == nil { timer = time.NewTimer(debounce) } else { if !timer.Stop() { select { case <-timer.C: default: } } timer.Reset(debounce) } timerC = timer.C case <-timerC: timerC = nil next, err := Load(path) if err != nil { // Keep the last valid snapshot. A partially-written file must not // take down a running process or publish invalid state. continue } r.Replace(MergeRuntimeConfig(r.Snapshot(), next)) case _, ok := <-errors: // fsnotify errors are intentionally non-fatal; the watcher remains // useful for subsequent events and Close always terminates it. if !ok { errors = nil if events == nil { return } } case <-stop: if timer != nil { timer.Stop() } return } } } func (r *Store) Close() { if r == nil { return } r.mu.Lock() watcher, stop, done := r.watcher, r.stop, r.done r.watcher = nil r.stop = nil r.done = nil r.mu.Unlock() if watcher == nil { return } close(stop) _ = watcher.Close() <-done } func resolvePath(path string) (string, error) { path = strings.TrimSpace(path) if path == "" { return "", ErrConfigPathRequired } absolute, err := filepath.Abs(path) if err != nil { return "", fmt.Errorf("resolve configuration path: %w", err) } if info, statErr := os.Stat(absolute); statErr == nil && info.IsDir() { absolute = filepath.Join(absolute, "config.yaml") } return filepath.Clean(absolute), nil } func newViper(path string) *viper.Viper { v := viper.New() v.SetConfigFile(path) v.SetConfigType(strings.TrimPrefix(filepath.Ext(path), ".")) v.SetEnvPrefix("KRA") v.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) v.AutomaticEnv() return v } func decode(v *viper.Viper) (*Config, error) { var config Config hook := mapstructure.ComposeDecodeHookFunc( mapstructure.StringToTimeDurationHookFunc(), mapstructure.StringToSliceHookFunc(","), ) if err := v.Unmarshal(&config, viper.DecodeHook(hook)); err != nil { return nil, err } return &config, nil } func setConfigPath(config *Config, path string) { if config == nil { return } if config.Admin == nil { config.Admin = &Admin{} } config.Admin.ConfigPath = path } // Every config type is plain data: scalars, pointers to structs, and slices of // those. clonePtr and cloneSlice cover both shapes, so a clone function below is // one line per pointer or slice field and nothing else. A field holding only // scalars needs no line at all — the struct assignment already copied it. func clonePtr[T any](value *T) *T { if value == nil { return nil } copied := *value return &copied } func cloneSlice[T any](values []*T, clone func(*T) *T) []*T { if values == nil { return nil } copied := make([]*T, len(values)) for i, item := range values { copied[i] = clone(item) } return copied } func cloneConfig(value *Config) *Config { if value == nil { return nil } copied := *value copied.Server = clonePtr(value.Server) if copied.Server != nil { copied.Server.HTTP = clonePtr(value.Server.HTTP) } copied.Data = cloneData(value.Data) copied.Admin = cloneAdmin(value.Admin) return &copied } func cloneData(value *Data) *Data { if value == nil { return nil } copied := *value copied.Database = clonePtr(value.Database) copied.Redis = cloneRedis(value.Redis) copied.Mongo = cloneMongo(value.Mongo) copied.DatabaseList = cloneSlice(value.DatabaseList, clonePtr) copied.RedisList = cloneSlice(value.RedisList, cloneRedis) return &copied } func cloneRedis(value *Redis) *Redis { copied := clonePtr(value) if copied != nil { copied.ClusterAddrs = slices.Clone(value.ClusterAddrs) } return copied } func cloneMongo(value *Mongo) *Mongo { copied := clonePtr(value) if copied != nil { copied.Hosts = cloneSlice(value.Hosts, clonePtr) } return copied } func cloneAdmin(value *Admin) *Admin { if value == nil { return nil } copied := *value copied.JWT = clonePtr(value.JWT) copied.Captcha = clonePtr(value.Captcha) copied.Local = clonePtr(value.Local) copied.Email = clonePtr(value.Email) copied.Media = clonePtr(value.Media) copied.System = clonePtr(value.System) copied.App = clonePtr(value.App) copied.DiskList = cloneSlice(value.DiskList, clonePtr) copied.Storage = cloneStorage(value.Storage) if copied.Zap = clonePtr(value.Zap); copied.Zap != nil { copied.Zap.FileOnlyModules = slices.Clone(value.Zap.FileOnlyModules) } if copied.CORS = clonePtr(value.CORS); copied.CORS != nil { copied.CORS.Whitelist = cloneSlice(value.CORS.Whitelist, clonePtr) } return &copied } func cloneStorage(value *Storage) *Storage { if value == nil { return nil } copied := *value copied.Qiniu = clonePtr(value.Qiniu) copied.AliyunOSS = clonePtr(value.AliyunOSS) copied.HuaweiOBS = clonePtr(value.HuaweiOBS) copied.TencentCOS = clonePtr(value.TencentCOS) copied.AWSS3 = clonePtr(value.AWSS3) copied.CloudflareR2 = clonePtr(value.CloudflareR2) copied.Minio = clonePtr(value.Minio) return &copied }