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:
2026-05-01 16:01:23 +08:00
parent ce2d8a2907
commit 618399f86d
61 changed files with 3186 additions and 496 deletions
+28 -1
View File
@@ -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 == "" {
+34 -5
View File
@@ -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
+361 -23
View File
@@ -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)}
}