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:
@@ -7,7 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
|
||||
configruntime "github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env"
|
||||
runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
@@ -279,23 +283,28 @@ type GenerationConfig struct {
|
||||
}
|
||||
|
||||
func Load(configPath string) (*Config, error) {
|
||||
v := viper.New()
|
||||
v.AutomaticEnv()
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
cfg, _, err := loadWithFiles(configPath)
|
||||
return cfg, err
|
||||
}
|
||||
|
||||
if err := readConfigWithFallback(v, configPath); err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
func loadWithFiles(configPath string) (*Config, []string, error) {
|
||||
configFile, err := readConfigWithFallback(configPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
|
||||
// Allow local overrides
|
||||
_ = mergeLocalOverrideWithFallback(v, configPath)
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
localConfigFile, err := mergeLocalOverrideWithFallback(configPath)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("merge local config: %w", err)
|
||||
}
|
||||
|
||||
applyEnvOverrides(&cfg)
|
||||
cfg, err := decodeResolvedConfig(configFile, localConfigFile)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
applyEnvOverrides(cfg)
|
||||
normalizeRabbitMQConfig(&cfg.RabbitMQ)
|
||||
normalizeSchedulerConfig(&cfg.Scheduler)
|
||||
normalizeMonitoringConfig(&cfg.MonitoringWorkers)
|
||||
@@ -303,30 +312,72 @@ func Load(configPath string) (*Config, error) {
|
||||
normalizeMembershipConfig(&cfg.Membership)
|
||||
normalizeBrandLibraryConfig(&cfg.BrandLibrary)
|
||||
|
||||
return &cfg, nil
|
||||
files := []string{configFile}
|
||||
if localConfigFile != "" {
|
||||
files = append(files, localConfigFile)
|
||||
}
|
||||
|
||||
return cfg, files, nil
|
||||
}
|
||||
|
||||
func readConfigWithFallback(v *viper.Viper, configPath string) error {
|
||||
func readConfigWithFallback(configPath string) (string, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range candidateConfigPaths(configPath, false) {
|
||||
v.SetConfigFile(candidate)
|
||||
if err := v.ReadInConfig(); err == nil {
|
||||
return nil
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
return lastErr
|
||||
return "", lastErr
|
||||
}
|
||||
|
||||
func mergeLocalOverrideWithFallback(v *viper.Viper, configPath string) error {
|
||||
func mergeLocalOverrideWithFallback(configPath string) (string, error) {
|
||||
for _, candidate := range candidateConfigPaths(configPath, true) {
|
||||
v.SetConfigFile(candidate)
|
||||
if err := v.MergeInConfig(); err == nil {
|
||||
return nil
|
||||
if _, err := os.Stat(candidate); err != nil {
|
||||
continue
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
return nil
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func decodeResolvedConfig(configFile, localConfigFile string) (*Config, error) {
|
||||
sources := []configruntime.Source{runtimefile.NewSource(configFile)}
|
||||
if localConfigFile != "" {
|
||||
sources = append(sources, runtimefile.NewSource(localConfigFile))
|
||||
}
|
||||
sources = append(sources, runtimeenv.NewSource())
|
||||
|
||||
resolved := configruntime.New(configruntime.WithSource(sources...))
|
||||
defer resolved.Close()
|
||||
|
||||
if err := resolved.Load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
settings := make(map[string]any)
|
||||
if err := resolved.Scan(&settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
decoder, err := mapstructure.NewDecoder(&mapstructure.DecoderConfig{
|
||||
Result: &cfg,
|
||||
TagName: "mapstructure",
|
||||
WeaklyTypedInput: true,
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
mapstructure.StringToTimeDurationHookFunc(),
|
||||
mapstructure.StringToSliceHookFunc(","),
|
||||
),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := decoder.Decode(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func normalizeMonitoringDispatchConfig(cfg *MonitoringDispatchConfig) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package config
|
||||
|
||||
type Provider interface {
|
||||
Current() *Config
|
||||
}
|
||||
|
||||
type staticProvider struct {
|
||||
cfg *Config
|
||||
}
|
||||
|
||||
func NewStaticProvider(cfg *Config) Provider {
|
||||
return staticProvider{cfg: cfg}
|
||||
}
|
||||
|
||||
func (p staticProvider) Current() *Config {
|
||||
return p.cfg
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package config
|
||||
|
||||
import "reflect"
|
||||
|
||||
type FieldChange struct {
|
||||
Path string
|
||||
Hot bool
|
||||
}
|
||||
|
||||
func Diff(previous, current *Config) []FieldChange {
|
||||
if previous == nil || current == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
changes := make([]FieldChange, 0)
|
||||
addChange := func(path string, hot bool) {
|
||||
changes = append(changes, FieldChange{Path: path, Hot: hot})
|
||||
}
|
||||
|
||||
if previous.Server != current.Server {
|
||||
addChange("server", false)
|
||||
}
|
||||
if previous.Database != current.Database {
|
||||
addChange("database", false)
|
||||
}
|
||||
if previous.MonitoringDatabase != current.MonitoringDatabase {
|
||||
addChange("monitoring_database", false)
|
||||
}
|
||||
if previous.RabbitMQ != current.RabbitMQ {
|
||||
addChange("rabbitmq", false)
|
||||
}
|
||||
if previous.Scheduler.HTTPHost != current.Scheduler.HTTPHost ||
|
||||
previous.Scheduler.HTTPPort != current.Scheduler.HTTPPort {
|
||||
addChange("scheduler.http", false)
|
||||
}
|
||||
if previous.Redis != current.Redis {
|
||||
addChange("redis", false)
|
||||
}
|
||||
if previous.Cache != current.Cache {
|
||||
addChange("cache", false)
|
||||
}
|
||||
if previous.Log != current.Log {
|
||||
addChange("log", false)
|
||||
}
|
||||
|
||||
if previous.Scheduler.InternalMetricsToken != current.Scheduler.InternalMetricsToken {
|
||||
addChange("scheduler.internal_metrics_token", true)
|
||||
}
|
||||
if previous.Scheduler.DispatchInterval != current.Scheduler.DispatchInterval ||
|
||||
previous.Scheduler.DispatchTimeout != current.Scheduler.DispatchTimeout ||
|
||||
previous.Scheduler.DispatchBatchSize != current.Scheduler.DispatchBatchSize ||
|
||||
previous.Scheduler.DispatchConcurrency != current.Scheduler.DispatchConcurrency ||
|
||||
previous.Scheduler.GenerationQueueBackpressureLimit != current.Scheduler.GenerationQueueBackpressureLimit {
|
||||
addChange("scheduler.dispatch", true)
|
||||
}
|
||||
if previous.MonitoringWorkers != current.MonitoringWorkers {
|
||||
addChange("monitoring_workers", true)
|
||||
}
|
||||
if previous.MonitoringDispatch != current.MonitoringDispatch {
|
||||
addChange("monitoring_dispatch", true)
|
||||
}
|
||||
if !reflect.DeepEqual(previous.Membership, current.Membership) {
|
||||
addChange("membership", true)
|
||||
}
|
||||
if previous.BrandLibrary != current.BrandLibrary {
|
||||
addChange("brand_library", true)
|
||||
}
|
||||
if previous.Qdrant != current.Qdrant {
|
||||
addChange("qdrant", true)
|
||||
}
|
||||
if previous.ObjectStorage != current.ObjectStorage {
|
||||
addChange("object_storage", true)
|
||||
}
|
||||
if previous.JWT != current.JWT {
|
||||
addChange("jwt", true)
|
||||
}
|
||||
if previous.LLM != current.LLM {
|
||||
addChange("llm", true)
|
||||
}
|
||||
if previous.Retrieval != current.Retrieval {
|
||||
addChange("retrieval", true)
|
||||
}
|
||||
if previous.Generation.QueueSize != current.Generation.QueueSize ||
|
||||
previous.Generation.WorkerConcurrency != current.Generation.WorkerConcurrency {
|
||||
addChange("generation.worker", false)
|
||||
}
|
||||
if previous.Generation.StreamEnabled != current.Generation.StreamEnabled ||
|
||||
previous.Generation.ArticleTimeout != current.Generation.ArticleTimeout {
|
||||
addChange("generation", true)
|
||||
}
|
||||
|
||||
return changes
|
||||
}
|
||||
|
||||
func RestartRequired(changes []FieldChange) bool {
|
||||
for _, change := range changes {
|
||||
if !change.Hot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ChangePaths(changes []FieldChange, hot bool) []string {
|
||||
paths := make([]string, 0, len(changes))
|
||||
for _, change := range changes {
|
||||
if change.Hot == hot {
|
||||
paths = append(paths, change.Path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 go-kratos
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,187 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("key not found")
|
||||
|
||||
type Observer func(string, Value)
|
||||
|
||||
type Config interface {
|
||||
Load() error
|
||||
Scan(any) error
|
||||
Value(string) Value
|
||||
Watch(string, Observer) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type config struct {
|
||||
opts options
|
||||
reader Reader
|
||||
cached sync.Map
|
||||
observers sync.Map
|
||||
watchers []Watcher
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func New(opts ...Option) Config {
|
||||
o := options{
|
||||
decoder: defaultDecoder,
|
||||
resolver: defaultResolver,
|
||||
merge: mergeMap,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(&o)
|
||||
}
|
||||
return &config{
|
||||
opts: o,
|
||||
reader: newReader(o),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) Load() error {
|
||||
for _, src := range c.opts.sources {
|
||||
kvs, err := src.Load()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = c.reader.Merge(kvs...); err != nil {
|
||||
return err
|
||||
}
|
||||
w, err := src.Watch()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c.watchers = append(c.watchers, w)
|
||||
go c.watch(w)
|
||||
}
|
||||
return c.reader.Resolve()
|
||||
}
|
||||
|
||||
func (c *config) Value(key string) Value {
|
||||
if v, ok := c.cached.Load(key); ok {
|
||||
return v.(Value)
|
||||
}
|
||||
if v, ok := c.reader.Value(key); ok {
|
||||
c.cached.Store(key, v)
|
||||
return v
|
||||
}
|
||||
return &errValue{err: ErrNotFound}
|
||||
}
|
||||
|
||||
func (c *config) Scan(v any) error {
|
||||
data, err := c.reader.Source()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return unmarshalJSON(data, v)
|
||||
}
|
||||
|
||||
func (c *config) Watch(key string, o Observer) error {
|
||||
if v := c.Value(key); v.Load() == nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
c.observers.Store(key, o)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *config) Close() error {
|
||||
var closeErr error
|
||||
c.closeOnce.Do(func() {
|
||||
for _, w := range c.watchers {
|
||||
if err := w.Stop(); err != nil && closeErr == nil {
|
||||
closeErr = err
|
||||
}
|
||||
}
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (c *config) watch(w Watcher) {
|
||||
for {
|
||||
kvs, err := w.Next()
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
continue
|
||||
}
|
||||
if err := c.reader.Merge(kvs...); err != nil {
|
||||
c.notifyRootObserver()
|
||||
continue
|
||||
}
|
||||
if err := c.reader.Resolve(); err != nil {
|
||||
c.notifyRootObserver()
|
||||
continue
|
||||
}
|
||||
c.notifyObservers()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) notifyRootObserver() {
|
||||
if observer, ok := c.observers.Load(""); ok {
|
||||
observer.(Observer)("", c.Value(""))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *config) notifyObservers() {
|
||||
if observer, ok := c.observers.Load(""); ok {
|
||||
next, ok := c.reader.Value("")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
current := c.Value("")
|
||||
current.Store(next.Load())
|
||||
observer.(Observer)("", current)
|
||||
}
|
||||
|
||||
c.cached.Range(func(key, value any) bool {
|
||||
path := key.(string)
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
previous := value.(Value)
|
||||
next, ok := c.reader.Value(path)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
if reflect.TypeOf(next.Load()) == reflect.TypeOf(previous.Load()) && !reflect.DeepEqual(next.Load(), previous.Load()) {
|
||||
previous.Store(next.Load())
|
||||
if observer, ok := c.observers.Load(path); ok {
|
||||
observer.(Observer)(path, previous)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
func mergeMap(dst, src any) error {
|
||||
dstMap, ok := dst.(*map[string]any)
|
||||
if !ok {
|
||||
return errors.New("merge destination must be *map[string]any")
|
||||
}
|
||||
srcMap, ok := src.(map[string]any)
|
||||
if !ok {
|
||||
return errors.New("merge source must be map[string]any")
|
||||
}
|
||||
deepMerge(*dstMap, srcMap)
|
||||
return nil
|
||||
}
|
||||
|
||||
func deepMerge(dst, src map[string]any) {
|
||||
for key, srcValue := range src {
|
||||
if srcChild, ok := srcValue.(map[string]any); ok {
|
||||
if dstChild, ok := dst[key].(map[string]any); ok {
|
||||
deepMerge(dstChild, srcChild)
|
||||
continue
|
||||
}
|
||||
}
|
||||
dst[key] = srcValue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// Package env is adapted from go-kratos/kratos config/env.
|
||||
//
|
||||
// Upstream: https://github.com/go-kratos/kratos/tree/v2.9.2/config/env
|
||||
// License: MIT, see ../LICENSE.kratos.
|
||||
package env
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
)
|
||||
|
||||
type env struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func NewSource(prefixes ...string) runtime.Source {
|
||||
return &env{prefixes: prefixes}
|
||||
}
|
||||
|
||||
func (e *env) Load() ([]*runtime.KeyValue, error) {
|
||||
return e.load(os.Environ()), nil
|
||||
}
|
||||
|
||||
func (e *env) load(envs []string) []*runtime.KeyValue {
|
||||
kvs := make([]*runtime.KeyValue, 0, len(envs))
|
||||
for _, item := range envs {
|
||||
key, value, _ := strings.Cut(item, "=")
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if len(e.prefixes) > 0 {
|
||||
prefix, ok := matchPrefix(e.prefixes, key)
|
||||
if !ok || key == prefix {
|
||||
continue
|
||||
}
|
||||
key = strings.TrimPrefix(key, prefix)
|
||||
key = strings.TrimPrefix(key, "_")
|
||||
}
|
||||
if key != "" {
|
||||
kvs = append(kvs, &runtime.KeyValue{
|
||||
Key: key,
|
||||
Value: []byte(value),
|
||||
})
|
||||
}
|
||||
}
|
||||
return kvs
|
||||
}
|
||||
|
||||
func (e *env) Watch() (runtime.Watcher, error) {
|
||||
return NewWatcher()
|
||||
}
|
||||
|
||||
func matchPrefix(prefixes []string, s string) (string, bool) {
|
||||
for _, p := range prefixes {
|
||||
if strings.HasPrefix(s, p) {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
)
|
||||
|
||||
var _ runtime.Watcher = (*watcher)(nil)
|
||||
|
||||
type watcher struct {
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
func NewWatcher() (runtime.Watcher, error) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &watcher{ctx: ctx, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func (w *watcher) Next() ([]*runtime.KeyValue, error) {
|
||||
<-w.ctx.Done()
|
||||
return nil, w.ctx.Err()
|
||||
}
|
||||
|
||||
func (w *watcher) Stop() error {
|
||||
w.cancel()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// Package file is adapted from go-kratos/kratos config/file.
|
||||
//
|
||||
// Upstream: https://github.com/go-kratos/kratos/tree/v2.9.2/config/file
|
||||
// License: MIT, see ../LICENSE.kratos.
|
||||
package file
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
)
|
||||
|
||||
type file struct {
|
||||
path string
|
||||
names map[string]struct{}
|
||||
}
|
||||
|
||||
func NewSource(path string, names ...string) runtime.Source {
|
||||
return &file{path: path, names: namesSet(names)}
|
||||
}
|
||||
|
||||
func (f *file) loadFile(path string) (*runtime.KeyValue, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &runtime.KeyValue{
|
||||
Key: info.Name(),
|
||||
Format: format(info.Name()),
|
||||
Value: data,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *file) loadDir(path string) ([]*runtime.KeyValue, error) {
|
||||
files, err := os.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kvs := make([]*runtime.KeyValue, 0, len(files))
|
||||
for _, item := range files {
|
||||
if item.IsDir() || strings.HasPrefix(item.Name(), ".") || !f.matchName(item.Name()) {
|
||||
continue
|
||||
}
|
||||
kv, err := f.loadFile(filepath.Join(path, item.Name()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
kvs = append(kvs, kv)
|
||||
}
|
||||
return kvs, nil
|
||||
}
|
||||
|
||||
func (f *file) Load() ([]*runtime.KeyValue, error) {
|
||||
info, err := os.Stat(f.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return f.loadDir(f.path)
|
||||
}
|
||||
kv, err := f.loadFile(f.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*runtime.KeyValue{kv}, nil
|
||||
}
|
||||
|
||||
func (f *file) Watch() (runtime.Watcher, error) {
|
||||
return newWatcher(f)
|
||||
}
|
||||
|
||||
func (f *file) match(path string) bool {
|
||||
return f.matchName(filepath.Base(path))
|
||||
}
|
||||
|
||||
func (f *file) matchName(name string) bool {
|
||||
if len(f.names) == 0 {
|
||||
return true
|
||||
}
|
||||
_, ok := f.names[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func namesSet(names []string) map[string]struct{} {
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
out[filepath.Base(name)] = struct{}{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package file
|
||||
|
||||
import "strings"
|
||||
|
||||
func format(name string) string {
|
||||
if idx := strings.LastIndexByte(name, '.'); idx >= 0 {
|
||||
return name[idx+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
)
|
||||
|
||||
type watcher struct {
|
||||
f *file
|
||||
fw *fsnotify.Watcher
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
stopOnce sync.Once
|
||||
}
|
||||
|
||||
func newWatcher(f *file) (runtime.Watcher, error) {
|
||||
fw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := fw.Add(f.path); err != nil {
|
||||
_ = fw.Close()
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &watcher{f: f, fw: fw, ctx: ctx, cancel: cancel}, nil
|
||||
}
|
||||
|
||||
func (w *watcher) Next() ([]*runtime.KeyValue, error) {
|
||||
for {
|
||||
select {
|
||||
case <-w.ctx.Done():
|
||||
return nil, w.ctx.Err()
|
||||
case event := <-w.fw.Events:
|
||||
if !w.f.match(event.Name) {
|
||||
continue
|
||||
}
|
||||
if event.Op == fsnotify.Rename {
|
||||
if _, err := os.Stat(event.Name); err == nil || os.IsExist(err) {
|
||||
if err := w.fw.Add(event.Name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(w.f.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := w.f.path
|
||||
if info.IsDir() {
|
||||
path = filepath.Join(w.f.path, filepath.Base(event.Name))
|
||||
}
|
||||
kv, err := w.f.loadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []*runtime.KeyValue{kv}, nil
|
||||
case err := <-w.fw.Errors:
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *watcher) Stop() error {
|
||||
var err error
|
||||
w.stopOnce.Do(func() {
|
||||
w.cancel()
|
||||
err = w.fw.Close()
|
||||
})
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Decoder func(*KeyValue, map[string]any) error
|
||||
type Resolver func(map[string]any) error
|
||||
type Merge func(dst, src any) error
|
||||
type Option func(*options)
|
||||
|
||||
type options struct {
|
||||
sources []Source
|
||||
decoder Decoder
|
||||
resolver Resolver
|
||||
merge Merge
|
||||
}
|
||||
|
||||
func WithSource(s ...Source) Option {
|
||||
return func(o *options) {
|
||||
o.sources = s
|
||||
}
|
||||
}
|
||||
|
||||
func WithDecoder(d Decoder) Option {
|
||||
return func(o *options) {
|
||||
o.decoder = d
|
||||
}
|
||||
}
|
||||
|
||||
func WithResolveActualTypes(enableConvertToType bool) Option {
|
||||
return func(o *options) {
|
||||
o.resolver = newActualTypesResolver(enableConvertToType)
|
||||
}
|
||||
}
|
||||
|
||||
func WithResolver(r Resolver) Option {
|
||||
return func(o *options) {
|
||||
o.resolver = r
|
||||
}
|
||||
}
|
||||
|
||||
func WithMergeFunc(m Merge) Option {
|
||||
return func(o *options) {
|
||||
o.merge = m
|
||||
}
|
||||
}
|
||||
|
||||
func defaultDecoder(src *KeyValue, target map[string]any) error {
|
||||
if src.Format == "" {
|
||||
keys := strings.Split(src.Key, ".")
|
||||
for i, key := range keys {
|
||||
if i == len(keys)-1 {
|
||||
target[key] = src.Value
|
||||
return nil
|
||||
}
|
||||
sub := make(map[string]any)
|
||||
target[key] = sub
|
||||
target = sub
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch strings.ToLower(src.Format) {
|
||||
case "json":
|
||||
return json.Unmarshal(src.Value, &target)
|
||||
case "yaml", "yml":
|
||||
return yaml.Unmarshal(src.Value, &target)
|
||||
}
|
||||
return fmt.Errorf("unsupported key: %s format: %s", src.Key, src.Format)
|
||||
}
|
||||
|
||||
func newActualTypesResolver(enableConvertToType bool) func(map[string]any) error {
|
||||
return func(input map[string]any) error {
|
||||
mapper := mapper(input)
|
||||
return resolver(input, mapper, enableConvertToType)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultResolver(input map[string]any) error {
|
||||
mapper := mapper(input)
|
||||
return resolver(input, mapper, false)
|
||||
}
|
||||
|
||||
func resolver(input map[string]any, mapper func(name string) string, toType bool) error {
|
||||
var resolve func(map[string]any) error
|
||||
resolve = func(sub map[string]any) error {
|
||||
for key, value := range sub {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
sub[key] = expand(typed, mapper, toType)
|
||||
case map[string]any:
|
||||
if err := resolve(typed); err != nil {
|
||||
return err
|
||||
}
|
||||
case []any:
|
||||
for i, item := range typed {
|
||||
switch child := item.(type) {
|
||||
case string:
|
||||
typed[i] = expand(child, mapper, toType)
|
||||
case map[string]any:
|
||||
if err := resolve(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
sub[key] = typed
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return resolve(input)
|
||||
}
|
||||
|
||||
func mapper(input map[string]any) func(name string) string {
|
||||
return func(name string) string {
|
||||
args := strings.SplitN(strings.TrimSpace(name), ":", 2)
|
||||
if value, ok := readValue(input, args[0]); ok {
|
||||
s, _ := value.String()
|
||||
return s
|
||||
}
|
||||
if len(args) > 1 {
|
||||
return args[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func convertToType(input string) any {
|
||||
if strings.HasPrefix(input, "\"") && strings.HasSuffix(input, "\"") {
|
||||
return strings.Trim(input, "\"")
|
||||
}
|
||||
if input == "true" || input == "false" {
|
||||
b, _ := strconv.ParseBool(input)
|
||||
return b
|
||||
}
|
||||
if strings.Contains(input, ".") {
|
||||
if f, err := strconv.ParseFloat(input, 64); err == nil {
|
||||
return f
|
||||
}
|
||||
}
|
||||
if i, err := strconv.ParseInt(input, 10, 64); err == nil {
|
||||
return i
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
var placeholderRegexp = regexp.MustCompile(`\${(.*?)}`)
|
||||
|
||||
func expand(s string, mapping func(string) string, toType bool) any {
|
||||
for _, match := range placeholderRegexp.FindAllStringSubmatch(s, -1) {
|
||||
if len(match) != 2 {
|
||||
continue
|
||||
}
|
||||
mapped := mapping(match[1])
|
||||
if toType {
|
||||
return convertToType(mapped)
|
||||
}
|
||||
s = strings.ReplaceAll(s, match[0], mapped)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Reader interface {
|
||||
Merge(...*KeyValue) error
|
||||
Value(string) (Value, bool)
|
||||
Source() ([]byte, error)
|
||||
Resolve() error
|
||||
}
|
||||
|
||||
type reader struct {
|
||||
opts options
|
||||
values map[string]any
|
||||
lock sync.Mutex
|
||||
}
|
||||
|
||||
func newReader(opts options) Reader {
|
||||
return &reader{
|
||||
opts: opts,
|
||||
values: make(map[string]any),
|
||||
lock: sync.Mutex{},
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reader) Merge(kvs ...*KeyValue) error {
|
||||
merged, err := r.cloneMap()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, kv := range kvs {
|
||||
next := make(map[string]any)
|
||||
if err := r.opts.decoder(kv, next); err != nil {
|
||||
return fmt.Errorf("decode config key %s: %w", kv.Key, err)
|
||||
}
|
||||
if err := r.opts.merge(&merged, convertMap(next)); err != nil {
|
||||
return fmt.Errorf("merge config key %s: %w", kv.Key, err)
|
||||
}
|
||||
}
|
||||
r.lock.Lock()
|
||||
r.values = merged
|
||||
r.lock.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *reader) Value(path string) (Value, bool) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
if strings.TrimSpace(path) == "" {
|
||||
value := &atomicValue{}
|
||||
value.Store(r.values)
|
||||
return value, true
|
||||
}
|
||||
return readValue(r.values, path)
|
||||
}
|
||||
|
||||
func (r *reader) Source() ([]byte, error) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
return marshalJSON(convertMap(r.values))
|
||||
}
|
||||
|
||||
func (r *reader) Resolve() error {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
return r.opts.resolver(r.values)
|
||||
}
|
||||
|
||||
func (r *reader) cloneMap() (map[string]any, error) {
|
||||
r.lock.Lock()
|
||||
defer r.lock.Unlock()
|
||||
return cloneMap(r.values)
|
||||
}
|
||||
|
||||
func cloneMap(src map[string]any) (map[string]any, error) {
|
||||
var buf bytes.Buffer
|
||||
gob.Register(map[string]any{})
|
||||
gob.Register([]any{})
|
||||
enc := gob.NewEncoder(&buf)
|
||||
dec := gob.NewDecoder(&buf)
|
||||
if err := enc.Encode(src); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var clone map[string]any
|
||||
if err := dec.Decode(&clone); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
func convertMap(src any) any {
|
||||
switch value := src.(type) {
|
||||
case map[string]any:
|
||||
dst := make(map[string]any, len(value))
|
||||
for k, v := range value {
|
||||
dst[k] = convertMap(v)
|
||||
}
|
||||
return dst
|
||||
case map[any]any:
|
||||
dst := make(map[string]any, len(value))
|
||||
for k, v := range value {
|
||||
dst[fmt.Sprint(k)] = convertMap(v)
|
||||
}
|
||||
return dst
|
||||
case []any:
|
||||
dst := make([]any, len(value))
|
||||
for k, v := range value {
|
||||
dst[k] = convertMap(v)
|
||||
}
|
||||
return dst
|
||||
case []byte:
|
||||
return string(value)
|
||||
default:
|
||||
return src
|
||||
}
|
||||
}
|
||||
|
||||
func readValue(values map[string]any, path string) (Value, bool) {
|
||||
next := values
|
||||
keys := strings.Split(path, ".")
|
||||
last := len(keys) - 1
|
||||
for idx, key := range keys {
|
||||
value, ok := next[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
if idx == last {
|
||||
atomic := &atomicValue{}
|
||||
atomic.Store(value)
|
||||
return atomic, true
|
||||
}
|
||||
child, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
next = child
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func marshalJSON(v any) ([]byte, error) {
|
||||
return json.Marshal(v)
|
||||
}
|
||||
|
||||
func unmarshalJSON(data []byte, v any) error {
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Package runtime is adapted from go-kratos/kratos config.
|
||||
//
|
||||
// Upstream: https://github.com/go-kratos/kratos/tree/v2.9.2/config
|
||||
// License: MIT, see LICENSE.kratos in this directory.
|
||||
package runtime
|
||||
|
||||
type KeyValue struct {
|
||||
Key string
|
||||
Value []byte
|
||||
Format string
|
||||
}
|
||||
|
||||
type Source interface {
|
||||
Load() ([]*KeyValue, error)
|
||||
Watch() (Watcher, error)
|
||||
}
|
||||
|
||||
type Watcher interface {
|
||||
Next() ([]*KeyValue, error)
|
||||
Stop() error
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Value interface {
|
||||
Bool() (bool, error)
|
||||
Int() (int64, error)
|
||||
Float() (float64, error)
|
||||
String() (string, error)
|
||||
Duration() (time.Duration, error)
|
||||
Slice() ([]Value, error)
|
||||
Map() (map[string]Value, error)
|
||||
Scan(any) error
|
||||
Load() any
|
||||
Store(any)
|
||||
}
|
||||
|
||||
type atomicValue struct {
|
||||
atomic.Value
|
||||
}
|
||||
|
||||
func (v *atomicValue) typeAssertError() error {
|
||||
return fmt.Errorf("type assert to %v failed", reflect.TypeOf(v.Load()))
|
||||
}
|
||||
|
||||
func (v *atomicValue) Bool() (bool, error) {
|
||||
switch value := v.Load().(type) {
|
||||
case bool:
|
||||
return value, nil
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return strconv.ParseBool(fmt.Sprint(value))
|
||||
case string:
|
||||
return strconv.ParseBool(value)
|
||||
}
|
||||
return false, v.typeAssertError()
|
||||
}
|
||||
|
||||
func (v *atomicValue) Int() (int64, error) {
|
||||
switch value := v.Load().(type) {
|
||||
case int:
|
||||
return int64(value), nil
|
||||
case int8:
|
||||
return int64(value), nil
|
||||
case int16:
|
||||
return int64(value), nil
|
||||
case int32:
|
||||
return int64(value), nil
|
||||
case int64:
|
||||
return value, nil
|
||||
case uint:
|
||||
return int64(value), nil
|
||||
case uint8:
|
||||
return int64(value), nil
|
||||
case uint16:
|
||||
return int64(value), nil
|
||||
case uint32:
|
||||
return int64(value), nil
|
||||
case uint64:
|
||||
return int64(value), nil
|
||||
case float32:
|
||||
return int64(value), nil
|
||||
case float64:
|
||||
return int64(value), nil
|
||||
case string:
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
return 0, v.typeAssertError()
|
||||
}
|
||||
|
||||
func (v *atomicValue) Float() (float64, error) {
|
||||
switch value := v.Load().(type) {
|
||||
case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return strconv.ParseFloat(fmt.Sprint(value), 64)
|
||||
case string:
|
||||
return strconv.ParseFloat(value, 64)
|
||||
}
|
||||
return 0, v.typeAssertError()
|
||||
}
|
||||
|
||||
func (v *atomicValue) String() (string, error) {
|
||||
switch value := v.Load().(type) {
|
||||
case string:
|
||||
return value, nil
|
||||
case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64:
|
||||
return fmt.Sprint(value), nil
|
||||
case []byte:
|
||||
return string(value), nil
|
||||
case fmt.Stringer:
|
||||
return value.String(), nil
|
||||
}
|
||||
return "", v.typeAssertError()
|
||||
}
|
||||
|
||||
func (v *atomicValue) Duration() (time.Duration, error) {
|
||||
value, err := v.Int()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return time.Duration(value), nil
|
||||
}
|
||||
|
||||
func (v *atomicValue) Slice() ([]Value, error) {
|
||||
values, ok := v.Load().([]any)
|
||||
if !ok {
|
||||
return nil, v.typeAssertError()
|
||||
}
|
||||
out := make([]Value, 0, len(values))
|
||||
for _, value := range values {
|
||||
item := &atomicValue{}
|
||||
item.Store(value)
|
||||
out = append(out, item)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (v *atomicValue) Map() (map[string]Value, error) {
|
||||
values, ok := v.Load().(map[string]any)
|
||||
if !ok {
|
||||
return nil, v.typeAssertError()
|
||||
}
|
||||
out := make(map[string]Value, len(values))
|
||||
for key, value := range values {
|
||||
item := &atomicValue{}
|
||||
item.Store(value)
|
||||
out[key] = item
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (v *atomicValue) Scan(obj any) error {
|
||||
data, err := json.Marshal(v.Load())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, obj)
|
||||
}
|
||||
|
||||
type errValue struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (v errValue) Bool() (bool, error) { return false, v.err }
|
||||
func (v errValue) Int() (int64, error) { return 0, v.err }
|
||||
func (v errValue) Float() (float64, error) { return 0, v.err }
|
||||
func (v errValue) Duration() (time.Duration, error) { return 0, v.err }
|
||||
func (v errValue) String() (string, error) { return "", v.err }
|
||||
func (v errValue) Scan(any) error { return v.err }
|
||||
func (v errValue) Load() any { return nil }
|
||||
func (v errValue) Store(any) {}
|
||||
func (v errValue) Slice() ([]Value, error) { return nil, v.err }
|
||||
func (v errValue) Map() (map[string]Value, error) { return nil, v.err }
|
||||
@@ -0,0 +1,297 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/geo-platform/tenant-api/internal/shared/config/runtime"
|
||||
runtimeenv "github.com/geo-platform/tenant-api/internal/shared/config/runtime/env"
|
||||
runtimefile "github.com/geo-platform/tenant-api/internal/shared/config/runtime/file"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
cfg *Config
|
||||
files []string
|
||||
runtime runtime.Config
|
||||
watchedFiles map[string]fileSnapshot
|
||||
updates chan struct{}
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
}
|
||||
|
||||
type ReloadEvent struct {
|
||||
Previous *Config
|
||||
Current *Config
|
||||
Files []string
|
||||
Changes []FieldChange
|
||||
}
|
||||
|
||||
func NewStore(configPath string) (*Store, error) {
|
||||
cfg, files, err := loadWithFiles(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
store := &Store{
|
||||
path: configPath,
|
||||
cfg: cfg,
|
||||
files: normalizeConfigFiles(files),
|
||||
watchedFiles: snapshotFiles(nil),
|
||||
updates: make(chan struct{}, 1),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
}
|
||||
store.watchedFiles = snapshotFiles(store.watchFiles())
|
||||
store.runtime = store.newRuntimeConfig()
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func NewStaticStore(cfg *Config) (*Store, error) {
|
||||
if cfg == nil {
|
||||
return nil, errors.New("config is nil")
|
||||
}
|
||||
return &Store{cfg: cfg}, nil
|
||||
}
|
||||
|
||||
func (s *Store) Current() *Config {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error {
|
||||
if s == nil {
|
||||
return errors.New("config store is nil")
|
||||
}
|
||||
if s.runtime == nil {
|
||||
s.runtime = s.newRuntimeConfig()
|
||||
}
|
||||
|
||||
if err := s.runtime.Load(); err != nil {
|
||||
return fmt.Errorf("load config runtime source: %w", err)
|
||||
}
|
||||
if err := s.runtime.Watch("", func(string, runtime.Value) {
|
||||
s.notifyReload()
|
||||
}); err != nil {
|
||||
_ = s.runtime.Close()
|
||||
return fmt.Errorf("watch config runtime source: %w", err)
|
||||
}
|
||||
|
||||
if logger != nil {
|
||||
logger.Info("config hot reload watcher started", zap.Strings("files", s.files))
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-s.ctx.Done():
|
||||
return nil
|
||||
case <-s.updates:
|
||||
s.reload(logger, onReload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
}
|
||||
if s.runtime != nil {
|
||||
_ = s.runtime.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) {
|
||||
next, files, err := loadWithFiles(s.path)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("config reload rejected", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
files = normalizeConfigFiles(files)
|
||||
s.mu.Lock()
|
||||
previous := s.cfg
|
||||
s.cfg = next
|
||||
s.files = files
|
||||
s.watchedFiles = snapshotFiles(s.watchFiles())
|
||||
s.mu.Unlock()
|
||||
|
||||
changes := Diff(previous, next)
|
||||
if logger != nil {
|
||||
logger.Info("config reloaded",
|
||||
zap.Strings("files", files),
|
||||
zap.Strings("hot_changes", ChangePaths(changes, true)),
|
||||
zap.Strings("restart_required_changes", ChangePaths(changes, false)),
|
||||
)
|
||||
}
|
||||
if onReload != nil {
|
||||
onReload(ReloadEvent{
|
||||
Previous: previous,
|
||||
Current: next,
|
||||
Files: files,
|
||||
Changes: changes,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) newRuntimeConfig() runtime.Config {
|
||||
return runtime.New(runtime.WithSource(
|
||||
runtimefile.NewSource(s.watchRoot(), s.watchNames()...),
|
||||
runtimeenv.NewSource(),
|
||||
))
|
||||
}
|
||||
|
||||
func (s *Store) watchRoot() string {
|
||||
for _, candidate := range candidateConfigPaths(s.path, false) {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
return filepath.Dir(candidate)
|
||||
}
|
||||
return filepath.Dir(s.path)
|
||||
}
|
||||
|
||||
func (s *Store) notifyReload() {
|
||||
if !s.consumeFileChange() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.updates <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) consumeFileChange() bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
next := snapshotFiles(s.watchFiles())
|
||||
if len(s.watchedFiles) == 0 {
|
||||
s.watchedFiles = next
|
||||
return true
|
||||
}
|
||||
changed := !fileSnapshotsEqual(s.watchedFiles, next)
|
||||
if changed {
|
||||
s.watchedFiles = next
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func (s *Store) watchNames() []string {
|
||||
names := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{})
|
||||
for _, local := range []bool{false, true} {
|
||||
for _, candidate := range candidateConfigPaths(s.path, local) {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
name := filepath.Base(candidate)
|
||||
if _, ok := seen[name]; ok {
|
||||
continue
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (s *Store) watchFiles() []string {
|
||||
files := make([]string, 0, 4)
|
||||
seen := make(map[string]struct{})
|
||||
for _, local := range []bool{false, true} {
|
||||
for _, candidate := range candidateConfigPaths(s.path, local) {
|
||||
if candidate == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(candidate)
|
||||
if err == nil {
|
||||
candidate = abs
|
||||
}
|
||||
candidate = filepath.Clean(candidate)
|
||||
if _, ok := seen[candidate]; ok {
|
||||
continue
|
||||
}
|
||||
seen[candidate] = struct{}{}
|
||||
files = append(files, candidate)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func normalizeConfigFiles(files []string) []string {
|
||||
seen := make(map[string]struct{}, len(files))
|
||||
normalized := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
if file == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(file)
|
||||
if err == nil {
|
||||
file = abs
|
||||
}
|
||||
file = filepath.Clean(file)
|
||||
if _, ok := seen[file]; ok {
|
||||
continue
|
||||
}
|
||||
seen[file] = struct{}{}
|
||||
normalized = append(normalized, file)
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
type fileSnapshot struct {
|
||||
size int64
|
||||
modTime time.Time
|
||||
exists bool
|
||||
}
|
||||
|
||||
func snapshotFiles(files []string) map[string]fileSnapshot {
|
||||
snapshots := make(map[string]fileSnapshot, len(files))
|
||||
for _, file := range normalizeConfigFiles(files) {
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
snapshots[file] = fileSnapshot{}
|
||||
continue
|
||||
}
|
||||
snapshots[file] = fileSnapshot{
|
||||
size: info.Size(),
|
||||
modTime: info.ModTime(),
|
||||
exists: true,
|
||||
}
|
||||
}
|
||||
return snapshots
|
||||
}
|
||||
|
||||
func fileSnapshotsEqual(a, b map[string]fileSnapshot) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for key, left := range a {
|
||||
right, ok := b[key]
|
||||
if !ok || left != right {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in New Issue
Block a user