From 28a2c8ca2bdd6b293af43cf54700776fd50b842c Mon Sep 17 00:00:00 2001 From: agent-bot Date: Tue, 29 Sep 2026 08:17:21 +0000 Subject: [PATCH] feat(config): instance-settings fifth layer (U10b core 2, KTD16/17) - /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) --- cmd/server/main.go | 59 ++++--- internal/config/config.go | 39 ++++- internal/config/explain_test.go | 4 +- internal/config/instance_settings.go | 180 ++++++++++++++++++++++ internal/config/instance_settings_test.go | 79 ++++++++++ 5 files changed, 324 insertions(+), 37 deletions(-) create mode 100644 internal/config/instance_settings.go create mode 100644 internal/config/instance_settings_test.go diff --git a/cmd/server/main.go b/cmd/server/main.go index c75a3aca..c54b0af2 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -47,22 +47,23 @@ func main() { backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, pi-native)") flag.Parse() - cfg, err := config.Load(*envPath) + cliMutate := func(c *config.Config) { + if *port > 0 { + c.HTTPPort = *port + } + if *host != "" { + c.HTTPHost = *host + } + if *backend != "" { + c.AgentBackend = *backend + } + } + cfg, _, err := config.LoadForServe(*envPath, cliMutate) if err != nil { fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err) os.Exit(1) } - if *port > 0 { - cfg.HTTPPort = *port - } - if *host != "" { - cfg.HTTPHost = *host - } - if *backend != "" { - cfg.AgentBackend = *backend - } - rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() @@ -190,20 +191,22 @@ func configCheck(args []string) { backend := fs.String("backend", "", "智能体后端覆盖") _ = fs.Parse(args) - cfg, err := config.Load(*envPath) + mut := func(c *config.Config) { + if *port > 0 { + c.HTTPPort = *port + } + if *host != "" { + c.HTTPHost = *host + } + if *backend != "" { + c.AgentBackend = *backend + } + } + cfg, _, err := config.LoadForServe(*envPath, mut) if err != nil { fmt.Fprintf(os.Stderr, "config check: 加载失败: %v\n", err) os.Exit(1) } - if *port > 0 { - cfg.HTTPPort = *port - } - if *host != "" { - cfg.HTTPHost = *host - } - if *backend != "" { - cfg.AgentBackend = *backend - } fmt.Println(cfg.MaskedSummary()) if issues := cfg.Check(); len(issues) > 0 { @@ -226,12 +229,7 @@ func configExplain(args []string) { backend := fs.String("backend", "", "智能体后端覆盖") _ = fs.Parse(args) - cfg, src, err := config.LoadWithSources(*envPath) - if err != nil { - fmt.Fprintf(os.Stderr, "config explain: 加载失败: %v\n", err) - os.Exit(1) - } - src, err = config.ApplyCLIOverrides(cfg, src, func(c *config.Config) { + mut := func(c *config.Config) { if *port > 0 { c.HTTPPort = *port } @@ -241,13 +239,14 @@ func configExplain(args []string) { if *backend != "" { c.AgentBackend = *backend } - }) + } + cfg, src, err := config.LoadForServe(*envPath, mut) if err != nil { - fmt.Fprintf(os.Stderr, "config explain: %v\n", err) + fmt.Fprintf(os.Stderr, "config explain: 加载失败: %v\n", err) os.Exit(1) } - fmt.Printf("config explain: 字段 %d 个(层序 cli > process-env > env-file > default)\n", len(src)) + fmt.Printf("config explain: 字段 %d 个(层序 instance-settings > cli > process-env > env-file > default)\n", len(src)) fmt.Print(config.FormatFieldSources(config.SortedFieldSources(src))) if issues := cfg.Check(); len(issues) > 0 { fmt.Fprintln(os.Stderr, "附注:当前配置未通过 check:") diff --git a/internal/config/config.go b/internal/config/config.go index 4f937bc1..c93517ae 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -100,16 +100,21 @@ func DefaultDataDir() string { return "./data/dev" } -// Load 加载配置(行为与历史版本一致)。 +// Load 加载配置(行为与历史版本一致;CLI 覆盖由调用方自行追加)。 func Load(path string) (*Config, error) { - cfg, _, err := LoadWithSources(path) + cfg, _, err := LoadWithSources(path, nil) return cfg, err } +// LoadForServe 供服务入口使用:CLI 覆盖在实例设置层之前应用(KTD17 层序)。 +func LoadForServe(path string, cliMutate func(*Config)) (*Config, map[string]FieldSource, error) { + return LoadWithSources(path, cliMutate) +} + // LoadWithSources 加载配置并给出字段级来源(KTD17 U10b)。 -// 层序:default → env-file(按文件顺序)→ process-env;CLI 覆盖由调用方 -// 在返回后应用并以 ApplyCLIOverrides 记录来源。 -func LoadWithSources(path string) (*Config, map[string]FieldSource, error) { +// 层序:default → env-file(按文件顺序)→ process-env → cli(可选钩子)→ +// instance-settings(SETTINGS_MODE=ignore 时跳过);最终整体校验。 +func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string]FieldSource, error) { var phases []struct { Layer string Snap map[string]string @@ -415,6 +420,30 @@ func LoadWithSources(path string) (*Config, map[string]FieldSource, error) { Snap map[string]string }{"process-env", snapshotFields(cfg)}) + // 显式 CLI 覆盖(第四层)——在实例设置之前生效。 + if cliMutate != nil { + cliMutate(cfg) + phases = append(phases, struct { + Layer string + Snap map[string]string + }{"cli", snapshotFields(cfg)}) + } + + // 实例持久化部署设置(第五层,最终生效;SETTINGS_MODE=ignore 跳过)。 + if !settingsModeIgnored() { + is, err := loadInstanceSettings(DefaultConfigRoot()) + if err != nil { + return nil, nil, err + } + if err := applyInstanceSettings(cfg, is); err != nil { + return nil, nil, err + } + phases = append(phases, struct { + Layer string + Snap map[string]string + }{"instance-settings", snapshotFields(cfg)}) + } + if err := validateProductionSecrets(cfg); err != nil { return nil, nil, err } diff --git a/internal/config/explain_test.go b/internal/config/explain_test.go index b6cf9838..b94cd751 100644 --- a/internal/config/explain_test.go +++ b/internal/config/explain_test.go @@ -14,7 +14,7 @@ func TestFieldSourcesLayerAttribution(t *testing.T) { } t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file - cfg, src, err := LoadWithSources(envf) + cfg, src, err := LoadWithSources(envf, nil) if err != nil { t.Fatalf("load: %v", err) } @@ -37,7 +37,7 @@ func TestFieldSourcesLayerAttribution(t *testing.T) { } func TestApplyCLIOverridesAttribution(t *testing.T) { - cfg, src, err := LoadWithSources("") + cfg, src, err := LoadWithSources("", nil) if err != nil { t.Fatal(err) } diff --git a/internal/config/instance_settings.go b/internal/config/instance_settings.go new file mode 100644 index 00000000..a6dc334c --- /dev/null +++ b/internal/config/instance_settings.go @@ -0,0 +1,180 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "time" +) + +// InstanceSettings 是实例持久化部署设置(KTD17 第五层): +// 内置默认 → .env → 进程 env → CLI → instance-settings(本层最终生效)。 +// 文件位于 /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()) +} diff --git a/internal/config/instance_settings_test.go b/internal/config/instance_settings_test.go new file mode 100644 index 00000000..bae7866c --- /dev/null +++ b/internal/config/instance_settings_test.go @@ -0,0 +1,79 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func writeInstanceSettings(t *testing.T, root, body string) { + t.Helper() + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "instance-settings.json"), []byte(body), 0o600); err != nil { + t.Fatal(err) + } +} + +func TestInstanceSettingsFinalLayerWins(t *testing.T) { + root := t.TempDir() + t.Setenv("CONFIG_ROOT", root) + writeInstanceSettings(t, root, `{"revision":3,"settings":{"http_port":9000,"agent_backend":"stub","agent_timeout":"15m"}}`) + + cfg, src, err := LoadWithSources("", func(c *Config) { c.HTTPPort = 8888 }) // CLI 也被实例设置覆盖 + if err != nil { + t.Fatalf("load: %v", err) + } + if cfg.HTTPPort != 9000 || cfg.AgentBackend != "stub" || cfg.AgentTimeout.String() != "15m0s" { + t.Fatalf("instance settings not applied: port=%d backend=%s timeout=%s", cfg.HTTPPort, cfg.AgentBackend, cfg.AgentTimeout) + } + if src["http_port"].Layer != "instance-settings" { + t.Fatalf("http_port layer=%s want instance-settings", src["http_port"].Layer) + } + if src["agent_timeout"].Layer != "instance-settings" { + t.Fatalf("agent_timeout layer=%s", src["agent_timeout"].Layer) + } +} + +func TestInstanceSettingsIgnoredMode(t *testing.T) { + root := t.TempDir() + t.Setenv("CONFIG_ROOT", root) + t.Setenv("SETTINGS_MODE", "ignore") + writeInstanceSettings(t, root, `{"settings":{"http_port":9000}}`) + cfg, err := Load("") + if err != nil { + t.Fatal(err) + } + if cfg.HTTPPort == 9000 { + t.Fatal("SETTINGS_MODE=ignore must skip the instance settings layer") + } +} + +func TestInstanceSettingsBootstrapDeny(t *testing.T) { + root := t.TempDir() + t.Setenv("CONFIG_ROOT", root) + writeInstanceSettings(t, root, `{"settings":{"env":"main"}}`) + if _, err := Load(""); err == nil { + t.Fatal("bootstrap field override must fail closed") + } +} + +func TestInstanceSettingsTypeMismatchFails(t *testing.T) { + root := t.TempDir() + t.Setenv("CONFIG_ROOT", root) + writeInstanceSettings(t, root, `{"settings":{"http_port":"not-a-number"}}`) + if _, err := Load(""); err == nil { + t.Fatal("type mismatch must fail closed") + } +} + +func TestInstanceSettingsPermissiveFileRejected(t *testing.T) { + root := t.TempDir() + t.Setenv("CONFIG_ROOT", root) + p := filepath.Join(root, "instance-settings.json") + _ = os.WriteFile(p, []byte(`{"settings":{"http_port":9000}}`), 0o666) + if _, err := Load(""); err == nil { + t.Fatal("group/world writable instance settings must be rejected") + } +}