- <config_root>/instance-settings.json is the final config layer: default > env-file(s) > process-env > cli > instance-settings - LoadForServe wires the CLI mutation hook BEFORE instance settings; serve/check/explain all use it; plain Load keeps legacy behavior - bootstrap denylist (env) fails closed (no self-locating file loop); SETTINGS_MODE=ignore skips the layer as the recovery path - reflection-based typed application: string/int/bool/duration(string or seconds)/string-array; unknown fields and type mismatches fail closed - group/world-writable settings file rejected; absent key = inherit - sources table gains the instance-settings layer; e2e smoke verified (override applies; ignore-mode reverts to default)
181 lines
5.6 KiB
Go
181 lines
5.6 KiB
Go
package config
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"reflect"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// InstanceSettings 是实例持久化部署设置(KTD17 第五层):
|
||
// 内置默认 → .env → 进程 env → CLI → instance-settings(本层最终生效)。
|
||
// 文件位于 <config_root>/instance-settings.json;config_root 经显式 CLI/env
|
||
// 定位,本文件不得自覆盖定位字段(bootstrap 自覆盖防护)。
|
||
type InstanceSettings struct {
|
||
Revision int `json:"revision,omitempty"`
|
||
Settings map[string]any `json:"settings"`
|
||
}
|
||
|
||
// bootstrapFields 是 instance-settings 永不生效的定位/身份字段
|
||
// (自覆盖会构成“用文件定位文件”的循环或变更实例身份)。
|
||
var bootstrapFields = map[string]bool{
|
||
"env": true, // 实例环境名属启动定位,仅 .env/进程 env/CLI 可定
|
||
}
|
||
|
||
// DefaultConfigRoot 解析配置根:显式 CONFIG_ROOT 进程变量,否则工作目录。
|
||
// (后续 U10c 增加 instance_id 与独立配置目录布局时扩展。)
|
||
func DefaultConfigRoot() string {
|
||
if v := os.Getenv("CONFIG_ROOT"); strings.TrimSpace(v) != "" {
|
||
return v
|
||
}
|
||
return "."
|
||
}
|
||
|
||
// InstanceSettingsPath 返回实例设置文件路径(相对 config_root)。
|
||
func InstanceSettingsPath(configRoot string) string {
|
||
return filepath.Join(configRoot, "instance-settings.json")
|
||
}
|
||
|
||
// settingsModeIgnored 报告运维是否显式跳过实例设置层(恢复路径)。
|
||
func settingsModeIgnored() bool {
|
||
return strings.TrimSpace(os.Getenv("SETTINGS_MODE")) == "ignore"
|
||
}
|
||
|
||
// loadInstanceSettings 读取并校验实例设置文件;缺失返回 nil。
|
||
func loadInstanceSettings(configRoot string) (*InstanceSettings, error) {
|
||
path := InstanceSettingsPath(configRoot)
|
||
fi, err := os.Stat(path)
|
||
if err != nil {
|
||
if os.IsNotExist(err) {
|
||
return nil, nil
|
||
}
|
||
return nil, fmt.Errorf("instance settings stat %s: %w", path, err)
|
||
}
|
||
if fi.Mode().Perm()&0o022 != 0 {
|
||
return nil, fmt.Errorf("instance settings %s must not be group/world writable (mode %o)", path, fi.Mode().Perm())
|
||
}
|
||
raw, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("instance settings read %s: %w", path, err)
|
||
}
|
||
var is InstanceSettings
|
||
if err := json.Unmarshal(raw, &is); err != nil {
|
||
return nil, fmt.Errorf("instance settings parse %s: %w", path, err)
|
||
}
|
||
if is.Settings == nil {
|
||
is.Settings = map[string]any{}
|
||
}
|
||
for k := range is.Settings {
|
||
if bootstrapFields[k] {
|
||
return nil, fmt.Errorf("instance settings %s: bootstrap field %q cannot be overridden here", path, k)
|
||
}
|
||
}
|
||
return &is, nil
|
||
}
|
||
|
||
// applyInstanceSettings 将设置按字段 json 名覆盖到 cfg;类型不匹配即失败
|
||
// (整体校验原则:宁可拒绝启动,不猜测转换)。缺省键=继承上层,不做删除语义。
|
||
func applyInstanceSettings(cfg *Config, is *InstanceSettings) error {
|
||
if cfg == nil || is == nil {
|
||
return nil
|
||
}
|
||
v := reflect.ValueOf(cfg).Elem()
|
||
t := v.Type()
|
||
byJSON := map[string]int{}
|
||
for i := 0; i < t.NumField(); i++ {
|
||
name := jsonNameOf(t.Field(i))
|
||
if name != "" {
|
||
byJSON[name] = i
|
||
}
|
||
}
|
||
for key, val := range is.Settings {
|
||
idx, ok := byJSON[key]
|
||
if !ok {
|
||
return fmt.Errorf("instance settings: unknown field %q", key)
|
||
}
|
||
fv := v.Field(idx)
|
||
if !fv.CanSet() {
|
||
return fmt.Errorf("instance settings: field %q not settable", key)
|
||
}
|
||
nv, err := coerceSettingValue(fv, val, key)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
fv.Set(nv)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func jsonNameOf(f reflect.StructField) string {
|
||
name := f.Tag.Get("json")
|
||
if i := strings.IndexByte(name, ','); i >= 0 {
|
||
name = name[:i]
|
||
}
|
||
if name == "" {
|
||
return strings.ToLower(f.Name)
|
||
}
|
||
return name
|
||
}
|
||
|
||
func coerceSettingValue(fv reflect.Value, val any, key string) (reflect.Value, error) {
|
||
switch fv.Type() {
|
||
case reflect.TypeOf(""):
|
||
s, ok := val.(string)
|
||
if !ok {
|
||
return fv, fmt.Errorf("instance settings: field %q wants string, got %T", key, val)
|
||
}
|
||
return reflect.ValueOf(s), nil
|
||
case reflect.TypeOf(0), reflect.TypeOf(int64(0)):
|
||
n, ok := val.(float64) // json.Number 默认解码为 float64
|
||
if !ok {
|
||
return fv, fmt.Errorf("instance settings: field %q wants number, got %T", key, val)
|
||
}
|
||
iv := int64(n)
|
||
if float64(iv) != n {
|
||
return fv, fmt.Errorf("instance settings: field %q is not an integer: %v", key, n)
|
||
}
|
||
return reflect.ValueOf(iv).Convert(fv.Type()), nil
|
||
case reflect.TypeOf(false):
|
||
b, ok := val.(bool)
|
||
if !ok {
|
||
return fv, fmt.Errorf("instance settings: field %q wants bool, got %T", key, val)
|
||
}
|
||
return reflect.ValueOf(b), nil
|
||
case reflect.TypeOf(time.Duration(0)):
|
||
switch x := val.(type) {
|
||
case string:
|
||
d, err := time.ParseDuration(x)
|
||
if err != nil {
|
||
return fv, fmt.Errorf("instance settings: field %q duration: %w", key, err)
|
||
}
|
||
return reflect.ValueOf(d), nil
|
||
case float64:
|
||
// 数字按秒解释(部署配置直觉单位),必须整秒。
|
||
if float64(int64(x)) != x {
|
||
return fv, fmt.Errorf("instance settings: field %q duration seconds not integer: %v", key, x)
|
||
}
|
||
return reflect.ValueOf(time.Duration(x) * time.Second), nil
|
||
default:
|
||
return fv, fmt.Errorf("instance settings: field %q wants duration string, got %T", key, val)
|
||
}
|
||
case reflect.TypeOf([]string(nil)):
|
||
arr, ok := val.([]any)
|
||
if !ok {
|
||
return fv, fmt.Errorf("instance settings: field %q wants string array, got %T", key, val)
|
||
}
|
||
out := make([]string, 0, len(arr))
|
||
for _, e := range arr {
|
||
s, ok := e.(string)
|
||
if !ok {
|
||
return fv, fmt.Errorf("instance settings: field %q array element must be string, got %T", key, e)
|
||
}
|
||
out = append(out, s)
|
||
}
|
||
return reflect.ValueOf(out), nil
|
||
}
|
||
return fv, fmt.Errorf("instance settings: field %q type %s not supported", key, fv.Type())
|
||
}
|