205 lines
4.9 KiB
Go
205 lines
4.9 KiB
Go
package config
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"git.yuntuo.hk/k2safe/go-omni/logx"
|
|
"gopkg.in/yaml.v3"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// DecodeFile merges a bounded JSON/YAML document into defaults. It uses JSON
|
|
// field names for both formats, rejects unknown fields and accepts durations
|
|
// such as "3s". Application-specific validation remains with the caller.
|
|
func DecodeFile[T any](path string, defaults T) (T, error) {
|
|
var zero T
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
defer f.Close()
|
|
data, err := io.ReadAll(io.LimitReader(f, (1<<20)+1))
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
if len(data) > 1<<20 {
|
|
return zero, errors.New("config: file exceeds 1 MiB")
|
|
}
|
|
var value any
|
|
switch strings.ToLower(filepath.Ext(path)) {
|
|
case ".yaml", ".yml":
|
|
dec := yaml.NewDecoder(bytes.NewReader(data))
|
|
if err := dec.Decode(&value); err != nil {
|
|
return zero, err
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != io.EOF {
|
|
return zero, errors.New("config: one YAML document required")
|
|
}
|
|
case ".json":
|
|
dec := json.NewDecoder(bytes.NewReader(data))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&value); err != nil {
|
|
return zero, err
|
|
}
|
|
var extra any
|
|
if err := dec.Decode(&extra); err != io.EOF {
|
|
return zero, errors.New("config: one JSON document required")
|
|
}
|
|
default:
|
|
return zero, errors.New("config: expected .json, .yaml or .yml")
|
|
}
|
|
if _, ok := value.(map[string]any); !ok {
|
|
return zero, errors.New("config: root must be an object")
|
|
}
|
|
value, err = durations(value, reflect.TypeOf(defaults))
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
// Deep copy defaults so errors cannot partially mutate caller-owned maps.
|
|
original, err := json.Marshal(defaults)
|
|
if err != nil {
|
|
return zero, err
|
|
}
|
|
var result T
|
|
if err = json.Unmarshal(original, &result); err != nil {
|
|
return zero, err
|
|
}
|
|
dec := json.NewDecoder(bytes.NewReader(encoded))
|
|
dec.DisallowUnknownFields()
|
|
if err = dec.Decode(&result); err != nil {
|
|
return zero, err
|
|
}
|
|
return result, nil
|
|
}
|
|
func durations(v any, t reflect.Type) (any, error) {
|
|
if t == nil {
|
|
return v, nil
|
|
}
|
|
for t.Kind() == reflect.Pointer {
|
|
t = t.Elem()
|
|
}
|
|
if t == reflect.TypeOf(time.Duration(0)) {
|
|
if s, ok := v.(string); ok {
|
|
d, err := time.ParseDuration(s)
|
|
return int64(d), err
|
|
}
|
|
return v, nil
|
|
}
|
|
if t.Kind() == reflect.Struct {
|
|
if m, ok := v.(map[string]any); ok {
|
|
for n := 0; n < t.NumField(); n++ {
|
|
field := t.Field(n)
|
|
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
|
if name == "-" {
|
|
continue
|
|
}
|
|
if name == "" {
|
|
name = field.Name
|
|
}
|
|
if item, exists := m[name]; exists {
|
|
converted, err := durations(item, field.Type)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m[name] = converted
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if t.Kind() == reflect.Slice {
|
|
if items, ok := v.([]any); ok {
|
|
for n, item := range items {
|
|
converted, err := durations(item, t.Elem())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items[n] = converted
|
|
}
|
|
}
|
|
}
|
|
return v, nil
|
|
}
|
|
func LoadFile(path, prefix string) (Config, error) {
|
|
cfg, err := DecodeFile(path, Default())
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
return applyEnv(cfg, prefix)
|
|
}
|
|
|
|
// Reloader only accepts log.level changes. Static changes and invalid files
|
|
// leave the current snapshot and logger untouched. Watch does not own a goroutine.
|
|
type Reloader struct {
|
|
mu sync.Mutex
|
|
path, prefix string
|
|
current Config
|
|
logger *logx.Logger
|
|
}
|
|
|
|
func NewReloader(path, prefix string, logger *logx.Logger) (*Reloader, error) {
|
|
cfg, err := LoadFile(path, prefix)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err = logger.SetLevel(cfg.Log.Level); err != nil {
|
|
return nil, err
|
|
}
|
|
return &Reloader{path: path, prefix: prefix, current: cfg, logger: logger}, nil
|
|
}
|
|
func (r *Reloader) Reload() error {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
next, err := LoadFile(r.path, r.prefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
comparable := next
|
|
comparable.Log.Level = r.current.Log.Level
|
|
if !reflect.DeepEqual(comparable, r.current) {
|
|
return errors.New("config: static fields changed; restart required")
|
|
}
|
|
if err = r.logger.SetLevel(next.Log.Level); err != nil {
|
|
return err
|
|
}
|
|
r.current = next
|
|
return nil
|
|
}
|
|
func (r *Reloader) Snapshot() Config {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
cfg := r.current
|
|
cfg.TrustedProxies = append([]string(nil), cfg.TrustedProxies...)
|
|
cfg.Log.RedactKeys = append([]string(nil), cfg.Log.RedactKeys...)
|
|
return cfg
|
|
}
|
|
func (r *Reloader) Watch(ctx context.Context, interval time.Duration, onError func(error)) error {
|
|
if ctx == nil || interval < 100*time.Millisecond {
|
|
return errors.New("config: context and interval >=100ms required")
|
|
}
|
|
ticker := time.NewTicker(interval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
case <-ticker.C:
|
|
if err := r.Reload(); err != nil && onError != nil {
|
|
onError(err)
|
|
}
|
|
}
|
|
}
|
|
}
|