7b4d7ccf68
Provide a unified ops console for inspecting, retrying and cancelling jobs across generation, template/kol assist, knowledge parse, desktop publish/task, compliance review and monitoring collect sources. Wires RabbitMQ for retry republish and consolidates the desktop_publish_jobs columns into the base migration. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
540 lines
13 KiB
Go
540 lines
13 KiB
Go
package config
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"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 {
|
|
Server sharedconfig.ServerConfig `mapstructure:"server"`
|
|
Database sharedconfig.DatabaseConfig `mapstructure:"database"`
|
|
MonitoringDatabase sharedconfig.DatabaseConfig `mapstructure:"monitoring_database"`
|
|
Redis sharedconfig.RedisConfig `mapstructure:"redis"`
|
|
Cache sharedconfig.CacheConfig `mapstructure:"cache"`
|
|
RabbitMQ sharedconfig.RabbitMQConfig `mapstructure:"rabbitmq"`
|
|
Log sharedconfig.LogConfig `mapstructure:"log"`
|
|
JWT JWTConfig `mapstructure:"jwt"`
|
|
DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"`
|
|
AdminUsers AdminUsersConfig `mapstructure:"admin_users"`
|
|
IPRegion IPRegionConfig `mapstructure:"ip_region"`
|
|
}
|
|
|
|
type JWTConfig struct {
|
|
Secret string `mapstructure:"secret"`
|
|
AccessTTL time.Duration `mapstructure:"access_ttl"`
|
|
}
|
|
|
|
type DefaultAdminConfig struct {
|
|
Username string `mapstructure:"username"`
|
|
Password string `mapstructure:"password"`
|
|
DisplayName string `mapstructure:"display_name"`
|
|
Email string `mapstructure:"email"`
|
|
}
|
|
|
|
type AdminUsersConfig struct {
|
|
DefaultPlanCode string `mapstructure:"default_plan_code"`
|
|
}
|
|
|
|
type IPRegionConfig struct {
|
|
V4XDBPath string `mapstructure:"v4_xdb_path"`
|
|
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) {
|
|
cfg, err := load(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 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 !reflect.DeepEqual(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.RabbitMQ != current.RabbitMQ {
|
|
add("rabbitmq", 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)
|
|
}
|
|
applySharedDefaults(settings)
|
|
|
|
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, 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 applySharedDefaults(settings map[string]any) {
|
|
cacheSettings := ensureMapSetting(settings, "cache")
|
|
if _, ok := cacheSettings["metrics_enabled"]; !ok {
|
|
cacheSettings["metrics_enabled"] = true
|
|
}
|
|
}
|
|
|
|
func ensureMapSetting(settings map[string]any, key string) map[string]any {
|
|
if settings == nil {
|
|
return map[string]any{}
|
|
}
|
|
if existing, ok := settings[key].(map[string]any); ok {
|
|
return existing
|
|
}
|
|
next := make(map[string]any)
|
|
settings[key] = next
|
|
return next
|
|
}
|
|
|
|
func normalizeConfig(cfg *Config) {
|
|
if cfg.Server.Port == 0 {
|
|
cfg.Server.Port = 8090
|
|
}
|
|
if strings.TrimSpace(cfg.Server.Mode) == "" {
|
|
cfg.Server.Mode = "debug"
|
|
}
|
|
sharedconfig.NormalizeServerConfig(&cfg.Server)
|
|
if strings.TrimSpace(cfg.Redis.Addr) == "" {
|
|
cfg.Redis.Addr = "localhost:6379"
|
|
}
|
|
sharedconfig.NormalizeRedisConfig(&cfg.Redis)
|
|
if strings.TrimSpace(cfg.Cache.Driver) == "" {
|
|
cfg.Cache.Driver = "redis"
|
|
}
|
|
sharedconfig.NormalizeCacheConfig(&cfg.Cache)
|
|
normalizeOpsRabbitMQConfig(&cfg.RabbitMQ)
|
|
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",
|
|
"trusted_proxies": sharedconfig.DefaultTrustedProxies(),
|
|
},
|
|
"redis": map[string]any{
|
|
"addr": "localhost:6379",
|
|
"db": 0,
|
|
},
|
|
"cache": map[string]any{
|
|
"driver": "redis",
|
|
},
|
|
"rabbitmq": map[string]any{},
|
|
"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
|
|
}
|
|
if v := os.Getenv("OPS_DEFAULT_ADMIN_USERNAME"); v != "" {
|
|
cfg.DefaultAdmin.Username = v
|
|
}
|
|
if v := os.Getenv("OPS_DEFAULT_ADMIN_PASSWORD"); v != "" {
|
|
cfg.DefaultAdmin.Password = v
|
|
}
|
|
if v := os.Getenv("OPS_DEFAULT_ADMIN_DISPLAY_NAME"); v != "" {
|
|
cfg.DefaultAdmin.DisplayName = v
|
|
}
|
|
if v := os.Getenv("OPS_DEFAULT_ADMIN_EMAIL"); v != "" {
|
|
cfg.DefaultAdmin.Email = v
|
|
}
|
|
if v := os.Getenv("OPS_ADMIN_USERS_DEFAULT_PLAN_CODE"); v != "" {
|
|
cfg.AdminUsers.DefaultPlanCode = v
|
|
}
|
|
if v := os.Getenv("OPS_IP_REGION_V4_XDB_PATH"); v != "" {
|
|
cfg.IPRegion.V4XDBPath = v
|
|
}
|
|
if v := os.Getenv("OPS_IP_REGION_V6_XDB_PATH"); v != "" {
|
|
cfg.IPRegion.V6XDBPath = v
|
|
}
|
|
if v := os.Getenv("OPS_SERVER_TRUSTED_PROXIES"); v != "" {
|
|
cfg.Server.TrustedProxies = strings.Split(v, ",")
|
|
}
|
|
if v := os.Getenv("OPS_RABBITMQ_URL"); v != "" {
|
|
cfg.RabbitMQ.URL = v
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func normalizeOpsRabbitMQConfig(cfg *sharedconfig.RabbitMQConfig) {
|
|
if cfg == nil {
|
|
return
|
|
}
|
|
if strings.TrimSpace(cfg.GenerationExchange) == "" {
|
|
cfg.GenerationExchange = "generation.task"
|
|
}
|
|
if strings.TrimSpace(cfg.GenerationRoutingKey) == "" {
|
|
cfg.GenerationRoutingKey = "generation.task.run"
|
|
}
|
|
if strings.TrimSpace(cfg.DesktopTaskEventExchange) == "" {
|
|
cfg.DesktopTaskEventExchange = "desktop.task.event"
|
|
}
|
|
if strings.TrimSpace(cfg.DesktopDispatchExchange) == "" {
|
|
cfg.DesktopDispatchExchange = "desktop.task.dispatch"
|
|
}
|
|
if strings.TrimSpace(cfg.DesktopDispatchPublishPrefix) == "" {
|
|
cfg.DesktopDispatchPublishPrefix = "publish"
|
|
}
|
|
if strings.TrimSpace(cfg.DesktopDispatchMonitorPrefix) == "" {
|
|
cfg.DesktopDispatchMonitorPrefix = "monitor"
|
|
}
|
|
if strings.TrimSpace(cfg.TemplateAssistExchange) == "" {
|
|
cfg.TemplateAssistExchange = "template.assist"
|
|
}
|
|
if strings.TrimSpace(cfg.TemplateAssistRoutingKey) == "" {
|
|
cfg.TemplateAssistRoutingKey = "template.assist.run"
|
|
}
|
|
if strings.TrimSpace(cfg.KolAssistExchange) == "" {
|
|
cfg.KolAssistExchange = "kol.assist"
|
|
}
|
|
if strings.TrimSpace(cfg.KolAssistRoutingKey) == "" {
|
|
cfg.KolAssistRoutingKey = "kol.assist.run"
|
|
}
|
|
if strings.TrimSpace(cfg.ComplianceReviewExchange) == "" {
|
|
cfg.ComplianceReviewExchange = "compliance.review"
|
|
}
|
|
if strings.TrimSpace(cfg.ComplianceReviewRoutingKey) == "" {
|
|
cfg.ComplianceReviewRoutingKey = "compliance.review.run"
|
|
}
|
|
if cfg.PublishChannelPoolSize <= 0 {
|
|
cfg.PublishChannelPoolSize = 16
|
|
}
|
|
}
|
|
|
|
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)}
|
|
}
|