U10b 核心二:instance-settings 第五层(最终生效层 + 恢复开关 + 自覆盖防护) #14
+29
-30
@@ -47,22 +47,23 @@ func main() {
|
|||||||
backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, pi-native)")
|
backend := flag.String("backend", "", "智能体后端调度覆盖 (stub, pi-agent, pi-native)")
|
||||||
flag.Parse()
|
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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
|
fmt.Fprintf(os.Stderr, "加载配置失败: %v\n", err)
|
||||||
os.Exit(1)
|
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)
|
rootCtx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
defer stop()
|
defer stop()
|
||||||
|
|
||||||
@@ -190,20 +191,22 @@ func configCheck(args []string) {
|
|||||||
backend := fs.String("backend", "", "智能体后端覆盖")
|
backend := fs.String("backend", "", "智能体后端覆盖")
|
||||||
_ = fs.Parse(args)
|
_ = 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 {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "config check: 加载失败: %v\n", err)
|
fmt.Fprintf(os.Stderr, "config check: 加载失败: %v\n", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
if *port > 0 {
|
|
||||||
cfg.HTTPPort = *port
|
|
||||||
}
|
|
||||||
if *host != "" {
|
|
||||||
cfg.HTTPHost = *host
|
|
||||||
}
|
|
||||||
if *backend != "" {
|
|
||||||
cfg.AgentBackend = *backend
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println(cfg.MaskedSummary())
|
fmt.Println(cfg.MaskedSummary())
|
||||||
if issues := cfg.Check(); len(issues) > 0 {
|
if issues := cfg.Check(); len(issues) > 0 {
|
||||||
@@ -226,12 +229,7 @@ func configExplain(args []string) {
|
|||||||
backend := fs.String("backend", "", "智能体后端覆盖")
|
backend := fs.String("backend", "", "智能体后端覆盖")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
cfg, src, err := config.LoadWithSources(*envPath)
|
mut := func(c *config.Config) {
|
||||||
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) {
|
|
||||||
if *port > 0 {
|
if *port > 0 {
|
||||||
c.HTTPPort = *port
|
c.HTTPPort = *port
|
||||||
}
|
}
|
||||||
@@ -241,13 +239,14 @@ func configExplain(args []string) {
|
|||||||
if *backend != "" {
|
if *backend != "" {
|
||||||
c.AgentBackend = *backend
|
c.AgentBackend = *backend
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
cfg, src, err := config.LoadForServe(*envPath, mut)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "config explain: %v\n", err)
|
fmt.Fprintf(os.Stderr, "config explain: 加载失败: %v\n", err)
|
||||||
os.Exit(1)
|
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)))
|
fmt.Print(config.FormatFieldSources(config.SortedFieldSources(src)))
|
||||||
if issues := cfg.Check(); len(issues) > 0 {
|
if issues := cfg.Check(); len(issues) > 0 {
|
||||||
fmt.Fprintln(os.Stderr, "附注:当前配置未通过 check:")
|
fmt.Fprintln(os.Stderr, "附注:当前配置未通过 check:")
|
||||||
|
|||||||
@@ -100,16 +100,21 @@ func DefaultDataDir() string {
|
|||||||
return "./data/dev"
|
return "./data/dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load 加载配置(行为与历史版本一致)。
|
// Load 加载配置(行为与历史版本一致;CLI 覆盖由调用方自行追加)。
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
cfg, _, err := LoadWithSources(path)
|
cfg, _, err := LoadWithSources(path, nil)
|
||||||
return cfg, err
|
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)。
|
// LoadWithSources 加载配置并给出字段级来源(KTD17 U10b)。
|
||||||
// 层序:default → env-file(按文件顺序)→ process-env;CLI 覆盖由调用方
|
// 层序:default → env-file(按文件顺序)→ process-env → cli(可选钩子)→
|
||||||
// 在返回后应用并以 ApplyCLIOverrides 记录来源。
|
// instance-settings(SETTINGS_MODE=ignore 时跳过);最终整体校验。
|
||||||
func LoadWithSources(path string) (*Config, map[string]FieldSource, error) {
|
func LoadWithSources(path string, cliMutate func(*Config)) (*Config, map[string]FieldSource, error) {
|
||||||
var phases []struct {
|
var phases []struct {
|
||||||
Layer string
|
Layer string
|
||||||
Snap map[string]string
|
Snap map[string]string
|
||||||
@@ -415,6 +420,30 @@ func LoadWithSources(path string) (*Config, map[string]FieldSource, error) {
|
|||||||
Snap map[string]string
|
Snap map[string]string
|
||||||
}{"process-env", snapshotFields(cfg)})
|
}{"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 {
|
if err := validateProductionSecrets(cfg); err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ func TestFieldSourcesLayerAttribution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file
|
t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file
|
||||||
|
|
||||||
cfg, src, err := LoadWithSources(envf)
|
cfg, src, err := LoadWithSources(envf, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("load: %v", err)
|
t.Fatalf("load: %v", err)
|
||||||
}
|
}
|
||||||
@@ -37,7 +37,7 @@ func TestFieldSourcesLayerAttribution(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestApplyCLIOverridesAttribution(t *testing.T) {
|
func TestApplyCLIOverridesAttribution(t *testing.T) {
|
||||||
cfg, src, err := LoadWithSources("")
|
cfg, src, err := LoadWithSources("", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user