Files
Agent-System/internal/config/instance_settings.go
T
agent-bot 28a2c8ca2b
CI / go-vet (pull_request) Successful in 14s
CI / short-tests-report (pull_request) Failing after 36s
CI / backup-restore-drill (pull_request) Successful in 26s
CI / web (pull_request) Successful in 31s
feat(config): instance-settings fifth layer (U10b core 2, KTD16/17)
- <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)
2026-09-29 08:17:21 +00:00

181 lines
5.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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())
}