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:
@@ -6,6 +6,7 @@ import (
|
||||
"net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
@@ -35,6 +36,7 @@ var phonePattern = regexp.MustCompile(`^\+?[0-9]{6,20}$`)
|
||||
type AdminUserService struct {
|
||||
users *repository.AdminUserRepository
|
||||
audits *AuditService
|
||||
configMu sync.RWMutex
|
||||
defaultPlanCode string
|
||||
}
|
||||
|
||||
@@ -50,6 +52,31 @@ func NewAdminUserService(users *repository.AdminUserRepository, audits *AuditSer
|
||||
}
|
||||
}
|
||||
|
||||
func (s *AdminUserService) UpdateDefaultPlanCode(defaultPlanCode string) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
defaultPlanCode = strings.TrimSpace(defaultPlanCode)
|
||||
if defaultPlanCode == "" {
|
||||
defaultPlanCode = "free"
|
||||
}
|
||||
s.configMu.Lock()
|
||||
s.defaultPlanCode = defaultPlanCode
|
||||
s.configMu.Unlock()
|
||||
}
|
||||
|
||||
func (s *AdminUserService) currentDefaultPlanCode() string {
|
||||
if s == nil {
|
||||
return "free"
|
||||
}
|
||||
s.configMu.RLock()
|
||||
defer s.configMu.RUnlock()
|
||||
if strings.TrimSpace(s.defaultPlanCode) == "" {
|
||||
return "free"
|
||||
}
|
||||
return s.defaultPlanCode
|
||||
}
|
||||
|
||||
type AdminUserView struct {
|
||||
ID int64 `json:"id"`
|
||||
Email *string `json:"email"`
|
||||
@@ -295,7 +322,7 @@ func (s *AdminUserService) Create(ctx context.Context, actor *Actor, in CreateAd
|
||||
}
|
||||
planCode := strings.TrimSpace(in.PlanCode)
|
||||
if planCode == "" {
|
||||
planCode = s.defaultPlanCode
|
||||
planCode = s.currentDefaultPlanCode()
|
||||
}
|
||||
role := strings.TrimSpace(in.Role)
|
||||
if role == "" {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -26,15 +27,31 @@ type OperatorClaims struct {
|
||||
}
|
||||
|
||||
type TokenIssuer struct {
|
||||
mu sync.RWMutex
|
||||
secret []byte
|
||||
accessTTL time.Duration
|
||||
}
|
||||
|
||||
func NewTokenIssuer(secret string, accessTTL time.Duration) *TokenIssuer {
|
||||
return &TokenIssuer{secret: []byte(secret), accessTTL: accessTTL}
|
||||
issuer := &TokenIssuer{}
|
||||
issuer.Update(secret, accessTTL)
|
||||
return issuer
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) AccessTTL() time.Duration { return t.accessTTL }
|
||||
func (t *TokenIssuer) Update(secret string, accessTTL time.Duration) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
t.secret = []byte(secret)
|
||||
t.accessTTL = accessTTL
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) AccessTTL() time.Duration {
|
||||
_, accessTTL := t.snapshot()
|
||||
return accessTTL
|
||||
}
|
||||
|
||||
type IssuedToken struct {
|
||||
AccessToken string
|
||||
@@ -42,8 +59,9 @@ type IssuedToken struct {
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, error) {
|
||||
secret, accessTTL := t.snapshot()
|
||||
now := time.Now()
|
||||
exp := now.Add(t.accessTTL)
|
||||
exp := now.Add(accessTTL)
|
||||
claims := OperatorClaims{
|
||||
OperatorID: operator.ID,
|
||||
Username: operator.Username,
|
||||
@@ -56,7 +74,7 @@ func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, err
|
||||
ExpiresAt: jwt.NewNumericDate(exp),
|
||||
},
|
||||
}
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(t.secret)
|
||||
signed, err := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(secret)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("sign ops token: %w", err)
|
||||
}
|
||||
@@ -64,11 +82,12 @@ func (t *TokenIssuer) Issue(operator *domain.OperatorAccount) (*IssuedToken, err
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) {
|
||||
secret, _ := t.snapshot()
|
||||
token, err := jwt.ParseWithClaims(raw, &OperatorClaims{}, func(tok *jwt.Token) (any, error) {
|
||||
if _, ok := tok.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", tok.Header["alg"])
|
||||
}
|
||||
return t.secret, nil
|
||||
return secret, nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -83,6 +102,16 @@ func (t *TokenIssuer) Parse(raw string) (*OperatorClaims, error) {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (t *TokenIssuer) snapshot() ([]byte, time.Duration) {
|
||||
if t == nil {
|
||||
return nil, 0
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
secret := append([]byte(nil), t.secret...)
|
||||
return secret, t.accessTTL
|
||||
}
|
||||
|
||||
type AuthService struct {
|
||||
accounts *repository.AccountRepository
|
||||
audits *AuditService
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/viper"
|
||||
"github.com/mitchellh/mapstructure"
|
||||
"go.uber.org/zap"
|
||||
|
||||
sharedconfig "github.com/geo-platform/tenant-api/internal/shared/config"
|
||||
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 {
|
||||
@@ -45,51 +52,324 @@ type IPRegionConfig struct {
|
||||
V6XDBPath string `mapstructure:"v6_xdb_path"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
cfg *Config
|
||||
runtime configruntime.Config
|
||||
watchedFiles map[string]fileSnapshot
|
||||
updates chan struct{}
|
||||
}
|
||||
|
||||
type ReloadEvent struct {
|
||||
Previous *Config
|
||||
Current *Config
|
||||
Changes []FieldChange
|
||||
}
|
||||
|
||||
type FieldChange struct {
|
||||
Path string
|
||||
Hot bool
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(path)
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
v.SetEnvPrefix("OPS")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
v.AutomaticEnv()
|
||||
func NewStore(path string) (*Store, error) {
|
||||
cfg, err := load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store := &Store{
|
||||
path: path,
|
||||
cfg: cfg,
|
||||
watchedFiles: snapshotFiles(watchFiles(path)),
|
||||
updates: make(chan struct{}, 1),
|
||||
}
|
||||
store.runtime = store.newRuntimeConfig()
|
||||
return store, nil
|
||||
}
|
||||
|
||||
v.SetDefault("server.port", 8090)
|
||||
v.SetDefault("server.mode", "debug")
|
||||
v.SetDefault("redis.addr", "localhost:6379")
|
||||
v.SetDefault("redis.db", 0)
|
||||
v.SetDefault("cache.driver", "redis")
|
||||
v.SetDefault("log.level", "info")
|
||||
v.SetDefault("log.format", "json")
|
||||
v.SetDefault("jwt.access_ttl", 8*time.Hour)
|
||||
v.SetDefault("default_admin.username", "admin")
|
||||
v.SetDefault("default_admin.display_name", "Administrator")
|
||||
v.SetDefault("admin_users.default_plan_code", "free")
|
||||
v.SetDefault("ip_region.v4_xdb_path", "")
|
||||
v.SetDefault("ip_region.v6_xdb_path", "")
|
||||
func (s *Store) Current() *Config {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.cfg
|
||||
}
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
func (s *Store) Watch(ctx context.Context, logger *zap.Logger, onReload func(ReloadEvent)) error {
|
||||
if s == nil {
|
||||
return fmt.Errorf("ops config store is nil")
|
||||
}
|
||||
if s.runtime == nil {
|
||||
s.runtime = s.newRuntimeConfig()
|
||||
}
|
||||
if err := s.runtime.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.runtime.Watch("", func(string, configruntime.Value) {
|
||||
s.notifyReload()
|
||||
}); err != nil {
|
||||
_ = s.runtime.Close()
|
||||
return err
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("ops config hot reload watcher started", zap.String("file", s.path))
|
||||
}
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-s.updates:
|
||||
s.reload(logger, onReload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) Close() {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
if s.runtime != nil {
|
||||
_ = s.runtime.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Store) reload(logger *zap.Logger, onReload func(ReloadEvent)) {
|
||||
next, err := load(s.path)
|
||||
if err != nil {
|
||||
if logger != nil {
|
||||
logger.Warn("ops config reload rejected", zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
previous := s.cfg
|
||||
s.cfg = next
|
||||
s.watchedFiles = snapshotFiles(watchFiles(s.path))
|
||||
s.mu.Unlock()
|
||||
|
||||
event := ReloadEvent{
|
||||
Previous: previous,
|
||||
Current: next,
|
||||
Changes: Diff(previous, next),
|
||||
}
|
||||
if onReload != nil {
|
||||
onReload(event)
|
||||
}
|
||||
if logger != nil {
|
||||
restartRequired := RestartRequired(event.Changes)
|
||||
logger.Info("ops config reloaded", zap.Bool("restart_required", restartRequired))
|
||||
if restartRequired {
|
||||
logger.Warn("ops config reload changed connection or listener settings; restart process to apply those fields")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(watchFiles(s.path))
|
||||
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) newRuntimeConfig() configruntime.Config {
|
||||
return configruntime.New(configruntime.WithSource(
|
||||
runtimefile.NewSource(filepath.Dir(s.path), watchNames(s.path)...),
|
||||
runtimeenv.NewSource("OPS"),
|
||||
))
|
||||
}
|
||||
|
||||
func Diff(previous, current *Config) []FieldChange {
|
||||
if previous == nil || current == nil {
|
||||
return nil
|
||||
}
|
||||
changes := make([]FieldChange, 0)
|
||||
add := func(path string, hot bool) {
|
||||
changes = append(changes, FieldChange{Path: path, Hot: hot})
|
||||
}
|
||||
|
||||
if previous.Server != current.Server {
|
||||
add("server", false)
|
||||
}
|
||||
if previous.Database != current.Database {
|
||||
add("database", false)
|
||||
}
|
||||
if previous.MonitoringDatabase != current.MonitoringDatabase {
|
||||
add("monitoring_database", false)
|
||||
}
|
||||
if previous.Redis != current.Redis {
|
||||
add("redis", false)
|
||||
}
|
||||
if previous.Cache != current.Cache {
|
||||
add("cache", false)
|
||||
}
|
||||
if previous.Log != current.Log {
|
||||
add("log", false)
|
||||
}
|
||||
if previous.IPRegion != current.IPRegion {
|
||||
add("ip_region", false)
|
||||
}
|
||||
if previous.JWT != current.JWT {
|
||||
add("jwt", true)
|
||||
}
|
||||
if previous.AdminUsers != current.AdminUsers {
|
||||
add("admin_users", true)
|
||||
}
|
||||
if previous.DefaultAdmin != current.DefaultAdmin {
|
||||
add("default_admin", true)
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
func RestartRequired(changes []FieldChange) bool {
|
||||
for _, change := range changes {
|
||||
if !change.Hot {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func load(path string) (*Config, error) {
|
||||
resolved := configruntime.New(configruntime.WithSource(
|
||||
runtimefile.NewSource(path),
|
||||
runtimeenv.NewSource("OPS"),
|
||||
))
|
||||
defer resolved.Close()
|
||||
|
||||
if err := resolved.Load(); err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
settings := defaultSettings()
|
||||
if err := resolved.Scan(&settings); err != nil {
|
||||
return nil, fmt.Errorf("scan config: %w", err)
|
||||
}
|
||||
|
||||
var cfg Config
|
||||
if err := v.Unmarshal(&cfg); err != nil {
|
||||
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, fmt.Errorf("unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := applyEnvOverrides(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizeConfig(&cfg)
|
||||
if cfg.JWT.Secret == "" {
|
||||
return nil, fmt.Errorf("jwt.secret is required (set jwt.secret in yaml or OPS_JWT_SECRET env)")
|
||||
}
|
||||
if cfg.MonitoringDatabase.DBName == "" {
|
||||
cfg.MonitoringDatabase = cfg.Database
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func normalizeConfig(cfg *Config) {
|
||||
if cfg.Server.Port == 0 {
|
||||
cfg.Server.Port = 8090
|
||||
}
|
||||
if strings.TrimSpace(cfg.Server.Mode) == "" {
|
||||
cfg.Server.Mode = "debug"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Redis.Addr) == "" {
|
||||
cfg.Redis.Addr = "localhost:6379"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Cache.Driver) == "" {
|
||||
cfg.Cache.Driver = "redis"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Log.Level) == "" {
|
||||
cfg.Log.Level = "info"
|
||||
}
|
||||
if strings.TrimSpace(cfg.Log.Format) == "" {
|
||||
cfg.Log.Format = "json"
|
||||
}
|
||||
if cfg.JWT.AccessTTL <= 0 {
|
||||
cfg.JWT.AccessTTL = 8 * time.Hour
|
||||
}
|
||||
if strings.TrimSpace(cfg.DefaultAdmin.Username) == "" {
|
||||
cfg.DefaultAdmin.Username = "admin"
|
||||
}
|
||||
if strings.TrimSpace(cfg.DefaultAdmin.DisplayName) == "" {
|
||||
cfg.DefaultAdmin.DisplayName = "Administrator"
|
||||
}
|
||||
if strings.TrimSpace(cfg.AdminUsers.DefaultPlanCode) == "" {
|
||||
cfg.AdminUsers.DefaultPlanCode = "free"
|
||||
}
|
||||
}
|
||||
|
||||
func defaultSettings() map[string]any {
|
||||
return map[string]any{
|
||||
"server": map[string]any{
|
||||
"port": 8090,
|
||||
"mode": "debug",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"addr": "localhost:6379",
|
||||
"db": 0,
|
||||
},
|
||||
"cache": map[string]any{
|
||||
"driver": "redis",
|
||||
},
|
||||
"log": map[string]any{
|
||||
"level": "info",
|
||||
"format": "json",
|
||||
},
|
||||
"jwt": map[string]any{
|
||||
"access_ttl": 8 * time.Hour,
|
||||
},
|
||||
"default_admin": map[string]any{
|
||||
"username": "admin",
|
||||
"display_name": "Administrator",
|
||||
},
|
||||
"admin_users": map[string]any{
|
||||
"default_plan_code": "free",
|
||||
},
|
||||
"ip_region": map[string]any{
|
||||
"v4_xdb_path": "",
|
||||
"v6_xdb_path": "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func applyEnvOverrides(cfg *Config) error {
|
||||
if v := os.Getenv("OPS_JWT_SECRET"); v != "" {
|
||||
cfg.JWT.Secret = v
|
||||
@@ -117,3 +397,61 @@ func applyEnvOverrides(cfg *Config) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
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 files {
|
||||
if strings.TrimSpace(file) == "" {
|
||||
continue
|
||||
}
|
||||
abs, err := filepath.Abs(file)
|
||||
if err == nil {
|
||||
file = abs
|
||||
}
|
||||
file = filepath.Clean(file)
|
||||
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
|
||||
}
|
||||
|
||||
func watchFiles(path string) []string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{path}
|
||||
}
|
||||
|
||||
func watchNames(path string) []string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil
|
||||
}
|
||||
return []string{filepath.Base(path)}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user