Files
Agent-System/internal/config/instance_settings.go
T
agent-bot 2ad1ef393a
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 27s
CI / web (pull_request) Successful in 32s
feat(bootstrap): instance identity, single-start lock, root checks (U10c slice 1)
- internal/bootstrap.EnsureInstance on the serve path: normalized writable
  roots, symlink-aware validation, data-root vs vault containment rejection,
  filesystem-root refusal
- instance-marker.json in data root: first boot creates; foreign instance
  ID fails closed (explicit adopt/migrate required, KTD18)
- instance.lock non-blocking flock detects duplicate starts locally without
  any governance tooling; ownership held until process exit
- Config.InstanceID (INSTANCE_ID env/file key; default 'default') joins the
  instance-settings bootstrap denylist (identity cannot be self-overwritten)
- auth is Bearer+localStorage per origin (no cookies) - browser isolation
  holds across ports; documented in the unit template
- deploy/systemd/agent-system@.service template (instance %i: env file,
  DATA_DIR, hardened service scope)
- tests: marker create/foreign reject, vault overlap, invalid IDs, root
  refusal, in-process duplicate-start detection
2026-09-29 08:32:15 +00:00

182 lines
5.7 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, // 实例环境名属启动定位
"instance_id": true, // 实例身份与数据根标记绑定,不可被部署文件改写
}
// 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())
}