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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user