Add a single boot-config pipeline (file/env/CLI) with env profiles and explain #5
@@ -3,3 +3,56 @@
|
||||
对话系统后端:Go 模块化单体,默认构建为单二进制(go:embed 内嵌前端与嵌入式 SQLite 存储)。组合 Agent-Native / Agent-Squad / Agent-Intent 生态,依赖 agent-system-core 契约。
|
||||
|
||||
契约与架构见 Agent-System-Documents 设计方案与架构文档;开发规范见 development-guidelines(Dev 仓 `development/guidelines`)。本仓为 Agent-System 多仓组合的组成部分。
|
||||
|
||||
## 启动配置(配置文件 / 环境变量 / 命令行)
|
||||
|
||||
启动参数走**单一解析管道**(`internal/config`,规范:`standards/configuration.md`),优先级从低到高:
|
||||
|
||||
```
|
||||
内置默认 → 显式配置文件(--config,环境段式)→ 环境变量(AGENT_SYSTEM_*)→ 显式 CLI
|
||||
```
|
||||
|
||||
只有实际传入的 CLI 参数参与覆盖(显式传默认值同样参与);删除 override 恢复继承。配置文件必须显式给出,环境段必须显式选择,不从 cwd 或分支名推断。任何未知键或类型错误都会在启动时定位报错(文件#环境段.键),不静默失效。
|
||||
|
||||
### 配置文件:环境段式(推荐)
|
||||
|
||||
一个文件承载 dev/test/prod 等多套环境,`--env`(或 `AGENT_SYSTEM_ENV`)选择段;也可以是平面单段文件(此时不能与 `--env` 同用):
|
||||
|
||||
```bash
|
||||
agent-system config init --out agent-system.json # 生成含 dev/test/prod 的模板(0600)
|
||||
agent-system config show --config agent-system.json --env prod # 只读诊断:逐键来源 + 脱敏
|
||||
agent-system serve --config agent-system.json --env dev # 启动 dev 环境
|
||||
AGENT_SYSTEM_CONFIG=/etc/agent-system.json AGENT_SYSTEM_ENV=prod agent-system serve
|
||||
```
|
||||
|
||||
示例见 `configs/agent-system.example.json`。段内键:
|
||||
|
||||
| 键 | 说明 |
|
||||
|---|---|
|
||||
| `addr` | 监听地址(默认 127.0.0.1:8787) |
|
||||
| `data` | 工作区根目录(SQLite 与工件默认父目录;默认 ./data) |
|
||||
| `plugins` | 插件目录(默认 ./plugins) |
|
||||
| `artifacts` | 工件对象目录(空 = `<data>/artifacts`;PG 档多实例可指向共享卷) |
|
||||
| `dsn` | PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档) |
|
||||
| `auth` | API key 认证:`auto`(PG 档开、嵌入式关)\| `on` \| `off` |
|
||||
| `mock` | 演示模式(布尔;系统层注入 `runtime.mock`,**不落库**) |
|
||||
| `settings` | 设置树覆盖(models/runtime/context/retrieval/ui 键值须符合设置 schema,未知键报错) |
|
||||
|
||||
`settings` 子树经系统层注入(位于内置默认与租户 user 层之间):模型密钥可经文件下发而不落库、设置页掩码显示;租户仍可在 user/project 层覆盖可调项。`GET /api/settings` 返回的 `system` 字段即该层(脱敏)。
|
||||
|
||||
### 环境变量(登记清单)
|
||||
|
||||
| 变量 | 对应键 |
|
||||
|---|---|
|
||||
| `AGENT_SYSTEM_CONFIG` / `AGENT_SYSTEM_ENV` | 配置文件路径 / 环境段名(空串视为未设置) |
|
||||
| `AGENT_SYSTEM_ADDR` / `AGENT_SYSTEM_DATA` / `AGENT_SYSTEM_PLUGINS` / `AGENT_SYSTEM_ARTIFACTS` | 对应启动键(不允许空值) |
|
||||
| `AGENT_SYSTEM_DSN` | 空串合法(显式回落嵌入式档) |
|
||||
| `AGENT_SYSTEM_AUTH` / `AGENT_SYSTEM_MOCK` | auto\|on\|off / ParseBool 词法(true/false/1/0) |
|
||||
|
||||
### 实例隔离
|
||||
|
||||
独立环境段默认隔离可写目录与监听入口;`config show` 会对各段 `data` 目录重叠(相同或嵌套)给出告警。同一文件多段共用 `plugins` 目录是合法的(声明资产只读加载)。
|
||||
|
||||
### 兼容性说明
|
||||
|
||||
旧行为 `--mock` 会把 `runtime.mock=true` 写入 user 设置(粘性残留);现改为启动注入、不落库。曾用 `--mock` 启动过的库,user 层可能残留 `runtime.mock=true`,需在设置页改回。
|
||||
|
||||
+114
-18
@@ -2,6 +2,8 @@
|
||||
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
|
||||
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
|
||||
//(多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
|
||||
// 启动配置走单一解析管道(internal/config):内置默认 → 配置文件(--config,环境段式)
|
||||
// → 环境变量(AGENT_SYSTEM_*)→ 显式 CLI;`config show` 输出来源与脱敏诊断。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,6 +13,7 @@ import (
|
||||
"os"
|
||||
|
||||
"agent-system-backend/internal/app"
|
||||
"agent-system-backend/internal/config"
|
||||
)
|
||||
|
||||
var version = "0.1.0-dev"
|
||||
@@ -23,40 +26,120 @@ func main() {
|
||||
return
|
||||
case "serve":
|
||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||
case "config":
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
case "apikey":
|
||||
os.Exit(runAPIKey(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
|
||||
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
|
||||
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
|
||||
mock := fs.Bool("mock", false, "演示模式(等同 runtime.mock=true,不调用模型)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档)")
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
||||
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
||||
fs.Parse(os.Args[1:])
|
||||
f := newServeFlagSet("serve")
|
||||
f.fs.Parse(os.Args[1:])
|
||||
|
||||
res, err := config.Resolve(config.Input{
|
||||
ConfigPath: *f.configPath, EnvName: *f.envName,
|
||||
Flags: config.FlagsFromFlagSet(f.fs),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, w := range res.Warnings {
|
||||
fmt.Fprintln(os.Stderr, "agent-system: warning:", w)
|
||||
}
|
||||
|
||||
if err := app.Run(app.Options{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
||||
Addr: res.Addr, DataDir: res.DataDir, PluginsDir: res.PluginsDir, ForceMock: res.Mock,
|
||||
StorageDSN: res.StorageDSN, ArtifactsDir: res.ArtifactsDir, AuthMode: res.AuthMode,
|
||||
SettingsOverlay: res.SettingsOverlay, ConfigSummary: res.Summary(),
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runAPIKey:密钥管理子命令(create/list/revoke)。
|
||||
// serveFlags 定义 serve 与 config show 共用的启动参数面(flag 名即配置键)。
|
||||
type serveFlags struct {
|
||||
fs *flag.FlagSet
|
||||
configPath *string
|
||||
envName *string
|
||||
}
|
||||
|
||||
func newServeFlagSet(name string) *serveFlags {
|
||||
fs := flag.NewFlagSet(name, flag.ExitOnError)
|
||||
f := &serveFlags{fs: fs}
|
||||
f.configPath = fs.String("config", "", "配置文件路径(环境段式或平面 JSON;空 = 不用文件层)")
|
||||
f.envName = fs.String("env", "", "配置文件环境段名(dev/test/prod…;文件含段时必填)")
|
||||
fs.String("addr", "127.0.0.1:8787", "监听地址")
|
||||
fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
|
||||
fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
|
||||
fs.Bool("mock", false, "演示模式(系统层注入 runtime.mock,不落库)")
|
||||
fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite 档)")
|
||||
fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
||||
fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
||||
return f
|
||||
}
|
||||
|
||||
// runConfig:只读诊断与模板生成(不创建数据库、不改业务目录;init 显式写目标文件)。
|
||||
func runConfig(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system config <show|init> [--config f --env prod | --out f]")
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "show":
|
||||
f := newServeFlagSet("config show")
|
||||
jsonOut := f.fs.Bool("json", false, "机器可读输出(同样脱敏)")
|
||||
f.fs.Parse(args[1:])
|
||||
res, err := config.Resolve(config.Input{
|
||||
ConfigPath: *f.configPath, EnvName: *f.envName,
|
||||
Flags: config.FlagsFromFlagSet(f.fs),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system config:", err)
|
||||
return 1
|
||||
}
|
||||
if *jsonOut {
|
||||
out, err := res.ExplainJSON()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system config:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
}
|
||||
fmt.Print(res.ExplainText())
|
||||
return 0
|
||||
case "init":
|
||||
fs := flag.NewFlagSet("config init", flag.ExitOnError)
|
||||
out := fs.String("out", "./agent-system.json", "目标文件(拒绝覆盖既有文件)")
|
||||
fs.Parse(args[1:])
|
||||
if err := config.InitFile(*out); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system config:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Printf("OK: %s(dev/test/prod 环境段;CHANGE_ME 为占位,权限 0600)\n", *out)
|
||||
fmt.Printf("下一步:agent-system serve --config %s --env dev # 或 AGENT_SYSTEM_CONFIG=%s AGENT_SYSTEM_ENV=dev\n", *out, *out)
|
||||
return 0
|
||||
default:
|
||||
fmt.Fprintln(os.Stderr, "config: 未知子命令", args[0])
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// runAPIKey:密钥管理子命令(create/list/revoke);存储档定位与 serve 同管道
|
||||
//(--config/--env 解析文件层,--dsn/--data 显式值优先)。
|
||||
func runAPIKey(args []string) int {
|
||||
// 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system apikey <create|list|revoke> [--owner x --name y]")
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system apikey <create|list|revoke> [--config f --env prod | --dsn d --data dir] --owner x [--name y]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
configPath := fs.String("config", "", "配置文件路径(环境段式或平面)")
|
||||
envName := fs.String("env", "", "配置文件环境段名")
|
||||
data := fs.String("data", "", "数据目录(未给 = 按解析管道取)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(未给 = 按解析管道取)")
|
||||
owner := fs.String("owner", "", "租户名(owner)")
|
||||
name := fs.String("name", "default", "密钥名称")
|
||||
fs.Parse(args[1:])
|
||||
@@ -64,9 +147,22 @@ func runAPIKey(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
// 显式 flag 参与 CLI 层;未给(空)不覆盖文件/环境层。
|
||||
flags := map[string]string{}
|
||||
if *data != "" {
|
||||
flags["data"] = *data
|
||||
}
|
||||
if *dsn != "" {
|
||||
flags["dsn"] = *dsn
|
||||
}
|
||||
res, err := config.Resolve(config.Input{ConfigPath: *configPath, EnvName: *envName, Flags: flags})
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey:", err)
|
||||
return 1
|
||||
}
|
||||
switch sub {
|
||||
case "create":
|
||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||
key, err := app.CreateAPIKey(res.StorageDSN, res.DataDir, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
@@ -75,7 +171,7 @@ func runAPIKey(args []string) int {
|
||||
fmt.Println(key)
|
||||
return 0
|
||||
case "list":
|
||||
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
||||
keys, err := app.ListAPIKeys(res.StorageDSN, res.DataDir, *owner)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey list:", err)
|
||||
return 1
|
||||
@@ -84,7 +180,7 @@ func runAPIKey(args []string) int {
|
||||
fmt.Println(string(out))
|
||||
return 0
|
||||
case "revoke":
|
||||
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
|
||||
ok, err := app.RevokeAPIKey(res.StorageDSN, res.DataDir, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
|
||||
return 1
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"dev": {
|
||||
"addr": "127.0.0.1:8787",
|
||||
"data": "./data-dev",
|
||||
"plugins": "./plugins",
|
||||
"mock": true,
|
||||
"settings": {
|
||||
"models": {
|
||||
"chat": {
|
||||
"baseUrl": "https://api.openai.com/v1",
|
||||
"apiKey": "CHANGE_ME",
|
||||
"model": "gpt-4o-mini"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"addr": "127.0.0.1:8788",
|
||||
"data": "./data-test",
|
||||
"plugins": "./plugins"
|
||||
},
|
||||
"prod": {
|
||||
"addr": "127.0.0.1:8787",
|
||||
"data": "/var/lib/agent-system",
|
||||
"plugins": "./plugins",
|
||||
"dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system?sslmode=disable",
|
||||
"auth": "auto",
|
||||
"settings": {
|
||||
"runtime": {
|
||||
"rateLimitPerMin": 300,
|
||||
"redisAddr": "127.0.0.1:6379"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+20
-6
@@ -39,6 +39,10 @@ type Options struct {
|
||||
StorageDSN string // 空 = 嵌入式 SQLite 档;非空 = PostgreSQL 档(多用户/多实例)
|
||||
ArtifactsDir string // PG 档工件对象目录(空 = <DataDir>/artifacts)
|
||||
AuthMode string // auto(默认:PG 档开、嵌入式关)| on | off
|
||||
|
||||
// 以下由启动配置管道(internal/config)解析注入:
|
||||
SettingsOverlay map[string]any // 配置文件 settings 子树 → 系统层(defaults 与 user 之间);不落库
|
||||
ConfigSummary string // 启动配置摘要(日志用,如 "file=agent-system.json env=prod")
|
||||
}
|
||||
|
||||
// Run 装配并阻塞服务直至 SIGINT/SIGTERM(优雅停机:中断在途 run、冲净事件缓冲)。
|
||||
@@ -66,16 +70,26 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("builtin tools: %w", err)
|
||||
}
|
||||
setSvc := settings.NewService(st)
|
||||
setSvc.SetSystemOverride(map[string]any{"storage": map[string]any{"dataDir": opt.DataDir}})
|
||||
// 系统层覆盖(启动注入,位于 defaults 与 user 之间;不落库):目录实际值、
|
||||
// mock 与配置文件 settings 子树。租户仍可在 user/project 层覆盖可调项。
|
||||
override := map[string]any{
|
||||
"storage": map[string]any{"dataDir": opt.DataDir},
|
||||
"plugins": map[string]any{"dir": opt.PluginsDir},
|
||||
}
|
||||
if opt.ForceMock {
|
||||
override["runtime"] = map[string]any{"mock": true}
|
||||
}
|
||||
if len(opt.SettingsOverlay) > 0 {
|
||||
override = core.MergeSettings(override, opt.SettingsOverlay)
|
||||
}
|
||||
setSvc.SetSystemOverride(override)
|
||||
if opt.ConfigSummary != "" {
|
||||
log.Printf("config: %s", opt.ConfigSummary)
|
||||
}
|
||||
profSvc := profiles.New(st)
|
||||
if err := profSvc.EnsureDefault(); err != nil {
|
||||
return fmt.Errorf("default profile: %w", err)
|
||||
}
|
||||
if opt.ForceMock {
|
||||
if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
convRunner := &runtime.LLMRunner{}
|
||||
subRunner := &runtime.SubLLMRunner{}
|
||||
|
||||
@@ -0,0 +1,425 @@
|
||||
// Package config 实现启动配置的单一解析管道(standards/configuration.md):
|
||||
// 内置默认 → 显式配置文件(环境段)→ 已登记进程环境变量 → 显式 CLI。
|
||||
// 统一解析为快照(Resolved)后由组合根消费;其余包不得绕过本包自行读取环境变量。
|
||||
// 配置文件必须显式给出(--config / AGENT_SYSTEM_CONFIG),不从 cwd 或父目录猜测;
|
||||
// 环境段(dev/test/prod…)也须显式选择(--env / AGENT_SYSTEM_ENV),不从分支名或目录推断。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
)
|
||||
|
||||
// Boot 是启动档:决定进程装配(监听地址、工作区目录、存储档、认证),不被租户设置覆盖。
|
||||
type Boot struct {
|
||||
Addr string // HTTP/SSE 监听地址
|
||||
DataDir string // 工作区根目录(SQLite/工件默认父目录)
|
||||
PluginsDir string // 插件目录
|
||||
ArtifactsDir string // 工件对象目录;空 = <DataDir>/artifacts
|
||||
StorageDSN string // PostgreSQL 档 DSN;空 = 嵌入式 SQLite 档
|
||||
AuthMode string // auto(PG 档开、嵌入式关)| on | off
|
||||
Mock bool // 演示模式(系统层注入 runtime.mock,不落库)
|
||||
}
|
||||
|
||||
// Resolved 是解析快照:启动档生效值、来源标注、设置树覆盖与实例隔离告警。
|
||||
type Resolved struct {
|
||||
Boot
|
||||
SettingsOverlay map[string]any // 配置文件 settings 子树(深合并注入系统层,用户/项目层仍可覆盖)
|
||||
Path string // 实际使用的配置文件路径;空 = 未使用文件层
|
||||
Env string // 选中的环境段名;空 = 平面文件或无文件层
|
||||
Sources map[string]string
|
||||
Warnings []string
|
||||
}
|
||||
|
||||
// Input 汇集除内置默认外的三层输入;Flags 只含实际传入的 CLI 参数(flag.Visit 采集)。
|
||||
type Input struct {
|
||||
ConfigPath string // --config;空时查 AGENT_SYSTEM_CONFIG
|
||||
EnvName string // --env;空时查 AGENT_SYSTEM_ENV
|
||||
Environ []string // 进程环境(nil = os.Environ();测试注入确定性环境)
|
||||
Flags map[string]string // 显式 CLI:键为启动键(addr/data/...),值为字符串形式
|
||||
}
|
||||
|
||||
// 已登记进程环境变量(配置面仅此清单;新增须同步 README 与 explain 输出)。
|
||||
const (
|
||||
EnvConfig = "AGENT_SYSTEM_CONFIG"
|
||||
EnvEnv = "AGENT_SYSTEM_ENV"
|
||||
)
|
||||
|
||||
var envVars = map[string]string{
|
||||
"addr": "AGENT_SYSTEM_ADDR",
|
||||
"data": "AGENT_SYSTEM_DATA",
|
||||
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||
"dsn": "AGENT_SYSTEM_DSN",
|
||||
"auth": "AGENT_SYSTEM_AUTH",
|
||||
"mock": "AGENT_SYSTEM_MOCK",
|
||||
}
|
||||
|
||||
var bootKeys = []string{"addr", "data", "plugins", "artifacts", "dsn", "auth", "mock"}
|
||||
|
||||
// Defaults 返回内置默认(与 CLI usage 文案保持一致)。
|
||||
func Defaults() Boot {
|
||||
return Boot{Addr: "127.0.0.1:8787", DataDir: "./data", PluginsDir: "./plugins", AuthMode: "auto"}
|
||||
}
|
||||
|
||||
// Resolve 按优先级合并四层来源并校验;任何错误定位到具体键与来源。
|
||||
func Resolve(in Input) (*Resolved, error) {
|
||||
env := in.Environ
|
||||
if env == nil {
|
||||
env = os.Environ()
|
||||
}
|
||||
envMap := parseEnviron(env)
|
||||
|
||||
res := &Resolved{Boot: Defaults(), Sources: map[string]string{}}
|
||||
for k := range envVars {
|
||||
res.Sources[k] = "default"
|
||||
}
|
||||
|
||||
path := in.ConfigPath
|
||||
if path == "" {
|
||||
path = envMap[EnvConfig] // 空串视为未设置(路径型变量不允许空值)
|
||||
}
|
||||
envName := in.EnvName
|
||||
if envName == "" {
|
||||
envName = envMap[EnvEnv]
|
||||
}
|
||||
if path != "" {
|
||||
if err := loadFile(res, path, envName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res.Path = path
|
||||
res.Env = envName
|
||||
} else if envName != "" {
|
||||
return nil, fmt.Errorf("config: --env %q 需要配置文件(--config 或 %s)", envName, EnvConfig)
|
||||
}
|
||||
|
||||
if err := applyEnv(res, envMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := applyFlags(res, in.Flags); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateBoot(res.Boot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Summary 供启动日志与 config show 顶栏:文件与环境段,或注明未使用文件层。
|
||||
func (r *Resolved) Summary() string {
|
||||
if r.Path == "" {
|
||||
return "内置默认(未提供配置文件)"
|
||||
}
|
||||
if r.Env == "" {
|
||||
return "file=" + r.Path
|
||||
}
|
||||
return "file=" + r.Path + " env=" + r.Env
|
||||
}
|
||||
|
||||
// ---- 文件层 ----
|
||||
|
||||
// loadFile 读取并校验配置文件(环境段式或平面式),应用选中段的值并做实例隔离核对。
|
||||
func loadFile(res *Resolved, path, envName string) error {
|
||||
body, err := parseFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body.flat != nil {
|
||||
if envName != "" {
|
||||
return fmt.Errorf("config: %s 是平面配置(无环境段),不能与 --env %q 同用", path, envName)
|
||||
}
|
||||
applySection(res, *body.flat)
|
||||
return nil
|
||||
}
|
||||
names := make([]string, 0, len(body.sections))
|
||||
for n := range body.sections {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
if envName == "" {
|
||||
return fmt.Errorf("config: %s 含环境段 [%s],须以 --env 或 %s 显式选择(不从目录或分支名推断)",
|
||||
path, strings.Join(names, " "), EnvEnv)
|
||||
}
|
||||
sec, ok := body.sections[envName]
|
||||
if !ok {
|
||||
return fmt.Errorf("config: %s 无环境段 %q(可用:%s)", path, envName, strings.Join(names, " "))
|
||||
}
|
||||
applySection(res, sec)
|
||||
warnInstanceIsolation(path, body.sections, res)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fileBody 是配置文件的两种合法形状:环境段集合(推荐,承载 dev/test/prod)或平面单段。
|
||||
type fileBody struct {
|
||||
sections map[string]section
|
||||
flat *section
|
||||
}
|
||||
|
||||
// section 是一个环境段(或平面文件)的已校验内容;source 用于来源标注与错误定位。
|
||||
type section struct {
|
||||
values map[string]any
|
||||
source string
|
||||
}
|
||||
|
||||
// parseFile 解析并整体校验文件:任一段的未知键/类型错误都会使文件不可用(防拼写静默失效)。
|
||||
func parseFile(path string) (*fileBody, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: 读取 %s: %w", path, err)
|
||||
}
|
||||
var root map[string]any
|
||||
if err := json.Unmarshal(data, &root); err != nil {
|
||||
return nil, fmt.Errorf("config: %s 不是合法 JSON: %w", path, err)
|
||||
}
|
||||
if len(root) == 0 {
|
||||
return nil, fmt.Errorf("config: %s 内容为空", path)
|
||||
}
|
||||
// 形状判定:已知启动键或 settings 出现在顶层 → 平面文件;否则视为环境段集合。
|
||||
for k := range root {
|
||||
if k == "settings" || isBootKey(k) {
|
||||
return &fileBody{flat: &(section{values: root, source: "file:" + path})}, nil
|
||||
}
|
||||
}
|
||||
sections := map[string]section{}
|
||||
for name, body := range root {
|
||||
m, ok := body.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config: %s#%s: 环境段必须是对象", path, name)
|
||||
}
|
||||
sec, err := parseSection(m, fmt.Sprintf("file:%s#%s", path, name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sections[name] = sec
|
||||
}
|
||||
return &fileBody{sections: sections}, nil
|
||||
}
|
||||
|
||||
// parseSection 校验段内键与类型:启动键 + settings 子树(子树按设置 schema 校验)。
|
||||
func parseSection(m map[string]any, source string) (section, error) {
|
||||
for k, v := range m {
|
||||
switch {
|
||||
case isBootKey(k):
|
||||
if err := checkBootJSONType(k, v, source); err != nil {
|
||||
return section{}, err
|
||||
}
|
||||
case k == "settings":
|
||||
sm, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
return section{}, fmt.Errorf("config: %s: settings 必须是对象", source)
|
||||
}
|
||||
if err := settings.ValidateTreeOverlay(sm); err != nil {
|
||||
return section{}, fmt.Errorf("config: %s: %v", source, err)
|
||||
}
|
||||
default:
|
||||
return section{}, fmt.Errorf("config: %s: 未知键 %q(启动键:%s;或 settings 子树)",
|
||||
source, k, strings.Join(bootKeys, "/"))
|
||||
}
|
||||
}
|
||||
return section{values: m, source: source}, nil
|
||||
}
|
||||
|
||||
func checkBootJSONType(k string, v any, source string) error {
|
||||
if k == "mock" {
|
||||
if _, ok := v.(bool); !ok {
|
||||
return fmt.Errorf("config: %s: %s 须为布尔值", source, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("config: %s: %s 须为字符串", source, k)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySection 把段中出现的键写入快照(JSON 显式出现即参与覆盖,含空串值)。
|
||||
func applySection(res *Resolved, sec section) {
|
||||
for k, v := range sec.values {
|
||||
if k == "settings" {
|
||||
if res.SettingsOverlay == nil {
|
||||
res.SettingsOverlay = map[string]any{}
|
||||
}
|
||||
res.SettingsOverlay = mergeMaps(res.SettingsOverlay, v.(map[string]any))
|
||||
continue
|
||||
}
|
||||
if k == "mock" {
|
||||
res.Mock = v.(bool)
|
||||
} else {
|
||||
res.setField(k, v.(string))
|
||||
}
|
||||
res.Sources[k] = sec.source
|
||||
}
|
||||
}
|
||||
|
||||
// warnInstanceIsolation 核对各环境段 data 目录(字面路径取绝对+清理后)是否重叠:
|
||||
// 独立实例默认隔离可写目录(standards/configuration.md);重叠仅告警不阻断,
|
||||
// 由部署者确认是否刻意共享。
|
||||
func warnInstanceIsolation(path string, sections map[string]section, res *Resolved) {
|
||||
type dirOf struct{ name, dir string }
|
||||
var list []dirOf
|
||||
for name, sec := range sections {
|
||||
if d, ok := sec.values["data"].(string); ok && d != "" {
|
||||
list = append(list, dirOf{name, absClean(d)})
|
||||
}
|
||||
}
|
||||
for i := 0; i < len(list); i++ {
|
||||
for j := i + 1; j < len(list); j++ {
|
||||
a, b := list[i], list[j]
|
||||
if a.dir == b.dir || containsDir(a.dir, b.dir) || containsDir(b.dir, a.dir) {
|
||||
res.Warnings = append(res.Warnings, fmt.Sprintf(
|
||||
"环境段 %s 与 %s 的 data 目录重叠(%s 与 %s)——独立实例默认隔离可写目录,请确认是否刻意",
|
||||
a.name, b.name, a.dir, b.dir))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsDir(parent, child string) bool {
|
||||
return strings.HasPrefix(child, parent+string(filepath.Separator))
|
||||
}
|
||||
|
||||
func absClean(p string) string {
|
||||
abs, err := filepath.Abs(p)
|
||||
if err != nil {
|
||||
return filepath.Clean(p)
|
||||
}
|
||||
return filepath.Clean(abs)
|
||||
}
|
||||
|
||||
// ---- 环境层与 CLI 层 ----
|
||||
|
||||
// applyEnv 应用已登记环境变量:变量出现即参与覆盖(含空串语义,见 setFrom)。
|
||||
func applyEnv(res *Resolved, envMap map[string]string) error {
|
||||
keys := make([]string, 0, len(envVars))
|
||||
for k := range envVars {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
v, ok := envMap[envVars[k]]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := setFrom(res, k, v, "env:"+envVars[k]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applyFlags 应用显式传入的 CLI(仅实际出现的参数参与;显式传默认值同样参与)。
|
||||
func applyFlags(res *Resolved, flags map[string]string) error {
|
||||
for _, k := range bootKeys {
|
||||
v, ok := flags[k]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := setFrom(res, k, v, "flag:--"+k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setFrom 写入单个启动键并校验该来源的值(空值/非法值就地报错,定位到来源)。
|
||||
// dsn 与 artifacts 允许空串(空 dsn = 显式嵌入式档;空 artifacts = 取 <data>/artifacts);
|
||||
// 其余键空串无效。mock 接受 strconv.ParseBool 词法。
|
||||
func setFrom(res *Resolved, k, v, source string) error {
|
||||
switch k {
|
||||
case "mock":
|
||||
b, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: %s: 布尔值须为 true/false(得到 %q)", source, v)
|
||||
}
|
||||
res.Mock = b
|
||||
case "dsn", "artifacts":
|
||||
res.setField(k, v)
|
||||
default:
|
||||
if v == "" {
|
||||
return fmt.Errorf("config: %s: 值为空(该键不允许空值)", source)
|
||||
}
|
||||
if k == "auth" && v != "auto" && v != "on" && v != "off" {
|
||||
return fmt.Errorf("config: %s: auth 须为 auto|on|off(得到 %q)", source, v)
|
||||
}
|
||||
res.setField(k, v)
|
||||
}
|
||||
res.Sources[k] = source
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateBoot(b Boot) error {
|
||||
if b.Addr == "" {
|
||||
return fmt.Errorf("config: addr 为空")
|
||||
}
|
||||
if b.DataDir == "" {
|
||||
return fmt.Errorf("config: data 为空")
|
||||
}
|
||||
if b.PluginsDir == "" {
|
||||
return fmt.Errorf("config: plugins 为空")
|
||||
}
|
||||
if b.AuthMode != "auto" && b.AuthMode != "on" && b.AuthMode != "off" {
|
||||
return fmt.Errorf("config: auth 须为 auto|on|off(得到 %q)", b.AuthMode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Boot) setField(k, v string) {
|
||||
switch k {
|
||||
case "addr":
|
||||
b.Addr = v
|
||||
case "data":
|
||||
b.DataDir = v
|
||||
case "plugins":
|
||||
b.PluginsDir = v
|
||||
case "artifacts":
|
||||
b.ArtifactsDir = v
|
||||
case "dsn":
|
||||
b.StorageDSN = v
|
||||
case "auth":
|
||||
b.AuthMode = v
|
||||
}
|
||||
}
|
||||
|
||||
func isBootKey(k string) bool {
|
||||
for _, b := range bootKeys {
|
||||
if k == b {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseEnviron(env []string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, e := range env {
|
||||
if i := strings.IndexByte(e, '='); i > 0 {
|
||||
out[e[:i]] = e[i+1:]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mergeMaps 深合并(与 core.MergeSettings 同语义;本地包装避免引入契约包耦合)。
|
||||
func mergeMaps(base, over map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(base)+len(over))
|
||||
for k, v := range base {
|
||||
out[k] = v
|
||||
}
|
||||
for k, v := range over {
|
||||
if bm, ok := out[k].(map[string]any); ok {
|
||||
if vm, ok2 := v.(map[string]any); ok2 {
|
||||
out[k] = mergeMaps(bm, vm)
|
||||
continue
|
||||
}
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeFile(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "agent-system.json")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestResolveDefaults(t *testing.T) {
|
||||
res, err := Resolve(Input{Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := Defaults()
|
||||
if res.Addr != d.Addr || res.DataDir != d.DataDir || res.PluginsDir != d.PluginsDir ||
|
||||
res.StorageDSN != "" || res.AuthMode != "auto" || res.Mock {
|
||||
t.Fatalf("默认快照异常: %+v", res.Boot)
|
||||
}
|
||||
for k, s := range res.Sources {
|
||||
if s != "default" {
|
||||
t.Fatalf("键 %s 来源应为 default,得到 %s", k, s)
|
||||
}
|
||||
}
|
||||
if res.Path != "" || res.Env != "" || len(res.Warnings) != 0 {
|
||||
t.Fatalf("未提供文件层时不应有文件/环境段/告警: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrecedenceFlagOverEnvOverFile(t *testing.T) {
|
||||
path := writeFile(t, `{"dev": {"addr": "127.0.0.1:5000"}, "prod": {"addr": "127.0.0.1:6000"}}`)
|
||||
// 文件层
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:6000" || res.Sources["addr"] != "file:"+path+"#prod" {
|
||||
t.Fatalf("文件层未生效: addr=%s source=%s", res.Addr, res.Sources["addr"])
|
||||
}
|
||||
// 环境层覆盖文件层
|
||||
res, err = Resolve(Input{ConfigPath: path, EnvName: "prod",
|
||||
Environ: []string{"AGENT_SYSTEM_ADDR=127.0.0.1:300"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:300" || res.Sources["addr"] != "env:AGENT_SYSTEM_ADDR" {
|
||||
t.Fatalf("环境层未覆盖文件层: addr=%s source=%s", res.Addr, res.Sources["addr"])
|
||||
}
|
||||
// CLI 层覆盖环境层
|
||||
res, err = Resolve(Input{ConfigPath: path, EnvName: "prod",
|
||||
Environ: []string{"AGENT_SYSTEM_ADDR=127.0.0.1:300"}, Flags: map[string]string{"addr": "127.0.0.1:9"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:9" || res.Sources["addr"] != "flag:--addr" {
|
||||
t.Fatalf("CLI 层未覆盖环境层: addr=%s source=%s", res.Addr, res.Sources["addr"])
|
||||
}
|
||||
// 删除环境与 CLI 覆盖 → 回落到文件层
|
||||
res, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:6000" {
|
||||
t.Fatalf("删除 override 后未恢复继承: addr=%s", res.Addr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsetFlagDoesNotParticipate(t *testing.T) {
|
||||
// 未显式传入的 CLI 不参与覆盖(仅 flag.Visit 采集显式项)。
|
||||
path := writeFile(t, `{"prod": {"addr": "127.0.0.1:6000"}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{},
|
||||
Flags: map[string]string{"mock": "true"}}) // 只传了 mock,未传 addr
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:6000" || !res.Mock {
|
||||
t.Fatalf("快照异常: addr=%s mock=%v", res.Addr, res.Mock)
|
||||
}
|
||||
if res.Sources["addr"] != "file:"+path+"#prod" || res.Sources["mock"] != "flag:--mock" {
|
||||
t.Fatalf("来源标注异常: %+v", res.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitDefaultFlagOverridesFile(t *testing.T) {
|
||||
// 显式传入且值恰为默认值:仍参与覆盖(configuration.md:实际传入即参与)。
|
||||
path := writeFile(t, `{"prod": {"addr": "127.0.0.1:6000"}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{},
|
||||
Flags: map[string]string{"addr": "127.0.0.1:8787"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:8787" || res.Sources["addr"] != "flag:--addr" {
|
||||
t.Fatalf("显式默认值未参与覆盖: addr=%s source=%s", res.Addr, res.Sources["addr"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionsSelection(t *testing.T) {
|
||||
path := writeFile(t, `{"dev": {"data": "./d1", "mock": true}, "prod": {"data": "./d2", "dsn": "postgres://u:p@h/db"}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "dev", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.DataDir != "./d1" || !res.Mock || res.StorageDSN != "" {
|
||||
t.Fatalf("dev 段未生效: %+v", res.Boot)
|
||||
}
|
||||
// 段名经环境变量选择
|
||||
res, err = Resolve(Input{ConfigPath: path, Environ: []string{"AGENT_SYSTEM_ENV=prod"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.DataDir != "./d2" || res.StorageDSN == "" || res.Mock {
|
||||
t.Fatalf("prod 段未生效: %+v", res.Boot)
|
||||
}
|
||||
// 文件路径经环境变量发现
|
||||
res, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_CONFIG=" + path, "AGENT_SYSTEM_ENV=prod"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Path != path || res.DataDir != "./d2" {
|
||||
t.Fatalf("AGENT_SYSTEM_CONFIG 发现异常: %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSectionsRequireExplicitEnv(t *testing.T) {
|
||||
path := writeFile(t, `{"dev": {"data": "./d1"}, "prod": {"data": "./d2"}}`)
|
||||
_, err := Resolve(Input{ConfigPath: path, Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "dev prod") {
|
||||
t.Fatalf("环境段文件未显式选段应报错并列出可用段: %v", err)
|
||||
}
|
||||
_, err = Resolve(Input{ConfigPath: path, EnvName: "staging", Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), `环境段 "staging"`) {
|
||||
t.Fatalf("未知段应定位报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatFileRejectsEnvFlag(t *testing.T) {
|
||||
path := writeFile(t, `{"addr": "127.0.0.1:6000", "settings": {"retrieval": {"topK": 8}}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Addr != "127.0.0.1:6000" || res.Env != "" {
|
||||
t.Fatalf("平面文件解析异常: %+v", res)
|
||||
}
|
||||
if res.SettingsOverlay == nil || int(res.SettingsOverlay["retrieval"].(map[string]any)["topK"].(float64)) != 8 {
|
||||
t.Fatalf("settings 子树未进入覆盖: %+v", res.SettingsOverlay)
|
||||
}
|
||||
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "平面配置") {
|
||||
t.Fatalf("平面文件 + --env 应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvWithoutConfigFile(t *testing.T) {
|
||||
_, err := Resolve(Input{EnvName: "prod", Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "--env") {
|
||||
t.Fatalf("无配置文件却给 --env 应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownBootKeyLocated(t *testing.T) {
|
||||
path := writeFile(t, `{"prod": {"adrr": "127.0.0.1:6000"}}`)
|
||||
_, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "adrr") || !strings.Contains(err.Error(), "#prod") {
|
||||
t.Fatalf("未知键应定位到文件与段: %v", err)
|
||||
}
|
||||
// 未选中段的未知键同样使文件不可用(文件是一个整体,防拼写静默潜伏)
|
||||
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err == nil {
|
||||
t.Fatal("未知键应报错")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBootJSONTypeErrors(t *testing.T) {
|
||||
path := writeFile(t, `{"prod": {"mock": "yes"}}`)
|
||||
_, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "mock 须为布尔值") {
|
||||
t.Fatalf("mock 类型错误应定位: %v", err)
|
||||
}
|
||||
path = writeFile(t, `{"prod": {"addr": 8080}}`)
|
||||
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err == nil || !strings.Contains(err.Error(), "addr 须为字符串") {
|
||||
t.Fatalf("addr 类型错误应定位: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsOverlayValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
json, wantErr string
|
||||
}{
|
||||
{`{"prod": {"settings": {"models": {"cht": {"model": "x"}}}}}`, "models.cht"},
|
||||
{`{"prod": {"settings": {"retrieval": {"topK": "5"}}}}`, "retrieval.topK"},
|
||||
{`{"prod": {"settings": {"runtime": {"mock": "true"}}}}`, "runtime.mock"},
|
||||
{`{"prod": {"settings": {"ui": {"theme": "blue"}}}}`, "ui.theme"},
|
||||
{`{"prod": {"settings": {"retrieval": {"topK": 8}, "ui": {"theme": "light"}}}}`, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
path := writeFile(t, c.json)
|
||||
_, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if c.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("合法设置子树不应报错: %v", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), c.wantErr) {
|
||||
t.Fatalf("应定位到 %s: %v", c.wantErr, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvVarValueValidation(t *testing.T) {
|
||||
// 空串语义:dsn 允许空(显式嵌入式档,覆盖文件层),addr 不允许空
|
||||
path := writeFile(t, `{"prod": {"dsn": "postgres://u:p@h/db"}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod",
|
||||
Environ: []string{"AGENT_SYSTEM_DSN="}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.StorageDSN != "" || res.Sources["dsn"] != "env:AGENT_SYSTEM_DSN" {
|
||||
t.Fatalf("空 DSN 应显式回落嵌入式档: %+v", res.Boot)
|
||||
}
|
||||
_, err = Resolve(Input{ConfigPath: path, EnvName: "prod",
|
||||
Environ: []string{"AGENT_SYSTEM_ADDR="}})
|
||||
if err == nil || !strings.Contains(err.Error(), "env:AGENT_SYSTEM_ADDR") {
|
||||
t.Fatalf("空 addr 应定位报错: %v", err)
|
||||
}
|
||||
_, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_MOCK=yes"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "AGENT_SYSTEM_MOCK") {
|
||||
t.Fatalf("非法布尔应定位报错: %v", err)
|
||||
}
|
||||
res, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_MOCK=1"}})
|
||||
if err != nil || !res.Mock {
|
||||
t.Fatalf("ParseBool 词法(1)应接受: %v %+v", err, res.Boot)
|
||||
}
|
||||
_, err = Resolve(Input{Environ: []string{"AGENT_SYSTEM_AUTH=maybe"}})
|
||||
if err == nil || !strings.Contains(err.Error(), "auto|on|off") {
|
||||
t.Fatalf("auth 非法值应报错: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceIsolationWarning(t *testing.T) {
|
||||
path := writeFile(t, `{"dev": {"data": "/srv/agent"}, "prod": {"data": "/srv/agent"}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Warnings) != 1 || !strings.Contains(res.Warnings[0], "dev") || !strings.Contains(res.Warnings[0], "prod") {
|
||||
t.Fatalf("data 目录重叠应告警: %+v", res.Warnings)
|
||||
}
|
||||
// 嵌套同样告警
|
||||
path = writeFile(t, `{"dev": {"data": "/srv/agent"}, "prod": {"data": "/srv/agent/data-dev"}}`)
|
||||
res, err = Resolve(Input{ConfigPath: path, EnvName: "dev", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Warnings) != 1 {
|
||||
t.Fatalf("嵌套 data 目录应告警: %+v", res.Warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplainMasksSecrets(t *testing.T) {
|
||||
path := writeFile(t, `{"prod": {"dsn": "postgres://agent:secret@127.0.0.1:5432/db",
|
||||
"settings": {"models": {"chat": {"apiKey": "sk-live-123"}}}}}`)
|
||||
res, err := Resolve(Input{ConfigPath: path, EnvName: "prod", Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
text := res.ExplainText()
|
||||
if strings.Contains(text, "secret@") || strings.Contains(text, "sk-live-123") {
|
||||
t.Fatalf("explain 泄密:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "agent:xxxxx@") {
|
||||
t.Fatalf("DSN 脱敏异常:\n%s", text)
|
||||
}
|
||||
if !strings.Contains(text, "••••••") {
|
||||
t.Fatalf("设置密钥未掩码:\n%s", text)
|
||||
}
|
||||
js, err := res.ExplainJSON()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(js), "secret@") || strings.Contains(string(js), "sk-live-123") {
|
||||
t.Fatalf("explain JSON 泄密: %s", js)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitAndTemplateResolves(t *testing.T) {
|
||||
out := filepath.Join(t.TempDir(), "conf", "agent-system.json")
|
||||
if err := InitFile(out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := InitFile(out); err == nil || !strings.Contains(err.Error(), "拒绝覆盖") {
|
||||
t.Fatalf("重复 init 应拒绝覆盖: %v", err)
|
||||
}
|
||||
// 模板各段都能被解析管道接受(自洽性)
|
||||
for _, env := range []string{"dev", "test", "prod"} {
|
||||
if _, err := Resolve(Input{ConfigPath: out, EnvName: env, Environ: []string{}}); err != nil {
|
||||
t.Fatalf("模板段 %s 解析失败: %v", env, err)
|
||||
}
|
||||
}
|
||||
info, err := os.Stat(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Fatalf("init 应写 0600 权限: %v", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagsFromFlagSet(t *testing.T) {
|
||||
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
|
||||
fs.String("addr", "127.0.0.1:8787", "")
|
||||
fs.String("data", "./data", "")
|
||||
fs.Bool("mock", false, "")
|
||||
fs.String("env", "", "") // 非启动键:不应进入覆盖
|
||||
if err := fs.Parse([]string{"--addr", "0.0.0.0:80", "--env", "prod"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := FlagsFromFlagSet(fs)
|
||||
want := map[string]string{"addr": "0.0.0.0:80"}
|
||||
if len(got) != len(want) || got["addr"] != want["addr"] {
|
||||
t.Fatalf("显式采集异常: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
// explain 诊断(只读)、示例模板与 init:standards/configuration.md 要求的
|
||||
// check/explain 能力——展示来源与脱敏状态,不创建数据库、不写业务目录(init 显式要求写盘除外)。
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
"agent-system-backend/internal/settings"
|
||||
)
|
||||
|
||||
// FlagsFromFlagSet 采集实际传入的 CLI 参数(flag.Visit 只访问显式设置的项);
|
||||
// 值统一转字符串,mock 由 ParseBool 解析。未传的参数不参与覆盖。
|
||||
func FlagsFromFlagSet(fs *flag.FlagSet) map[string]string {
|
||||
out := map[string]string{}
|
||||
fs.Visit(func(f *flag.Flag) {
|
||||
if isBootKey(f.Name) {
|
||||
out[f.Name] = f.Value.String()
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// ExplainText 渲染人读诊断:逐键生效值与来源、设置树覆盖(脱敏)、实例隔离告警。
|
||||
func (r *Resolved) ExplainText() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "agent-system 配置解析(优先级:默认 → 文件 → 环境 → CLI)\n")
|
||||
fmt.Fprintf(&b, "config: %s\n", r.Summary())
|
||||
hints := map[string]string{
|
||||
"dsn": hintDSN(r.StorageDSN),
|
||||
"artifacts": hintArtifacts(r.ArtifactsDir),
|
||||
"auth": hintAuth(r.AuthMode),
|
||||
}
|
||||
for _, k := range bootKeys {
|
||||
v := r.fieldString(k)
|
||||
if h := hints[k]; h != "" {
|
||||
v = h
|
||||
}
|
||||
fmt.Fprintf(&b, " %-10s %-46s ← %s\n", k, v, r.Sources[k])
|
||||
}
|
||||
if len(r.SettingsOverlay) > 0 {
|
||||
enc, _ := json.MarshalIndent(settings.Masked(core.EffectiveSettings(r.SettingsOverlay)), "", " ")
|
||||
fmt.Fprintf(&b, "settings 覆盖(系统层注入,用户/项目层可覆盖;密钥脱敏):\n %s\n", string(enc))
|
||||
}
|
||||
for _, w := range r.Warnings {
|
||||
fmt.Fprintf(&b, "warning: %s\n", w)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ExplainJSON 渲染机器可读诊断(同样脱敏)。
|
||||
func (r *Resolved) ExplainJSON() ([]byte, error) {
|
||||
out := struct {
|
||||
ConfigPath string `json:"configPath,omitempty"`
|
||||
Env string `json:"env,omitempty"`
|
||||
Boot map[string]string `json:"boot"`
|
||||
Sources map[string]string `json:"sources"`
|
||||
SettingsOverlay map[string]any `json:"settingsOverlay,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
}{
|
||||
ConfigPath: r.Path,
|
||||
Env: r.Env,
|
||||
Boot: map[string]string{
|
||||
"addr": r.Addr, "data": r.DataDir, "plugins": r.PluginsDir,
|
||||
"artifacts": r.ArtifactsDir, "dsn": maskDSN(r.StorageDSN),
|
||||
"auth": r.AuthMode, "mock": fmt.Sprintf("%v", r.Mock),
|
||||
},
|
||||
Sources: r.Sources,
|
||||
SettingsOverlay: settings.Masked(core.EffectiveSettings(r.SettingsOverlay)),
|
||||
Warnings: r.Warnings,
|
||||
}
|
||||
if r.SettingsOverlay == nil {
|
||||
out.SettingsOverlay = nil
|
||||
}
|
||||
return json.MarshalIndent(out, "", " ")
|
||||
}
|
||||
|
||||
func (r *Resolved) fieldString(k string) string {
|
||||
switch k {
|
||||
case "addr":
|
||||
return r.Addr
|
||||
case "data":
|
||||
return r.DataDir
|
||||
case "plugins":
|
||||
return r.PluginsDir
|
||||
case "artifacts":
|
||||
return r.ArtifactsDir
|
||||
case "dsn":
|
||||
return maskDSN(r.StorageDSN)
|
||||
case "auth":
|
||||
return r.AuthMode
|
||||
case "mock":
|
||||
return fmt.Sprintf("%v", r.Mock)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hintDSN(dsn string) string {
|
||||
if dsn == "" {
|
||||
return "(未设置 = 嵌入式 SQLite 档)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hintArtifacts(dir string) string {
|
||||
if dir == "" {
|
||||
return "(未设置 = <data>/artifacts)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hintAuth(mode string) string {
|
||||
if mode == "auto" {
|
||||
return "auto(PG 档自动开启认证)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// maskDSN 脱敏 DSN 中的密码(URL 形式经 net/url Redacted;libpq key=value 形式掩掉 password 值)。
|
||||
func maskDSN(dsn string) string {
|
||||
if dsn == "" {
|
||||
return ""
|
||||
}
|
||||
if u, err := url.Parse(dsn); err == nil && u.Scheme != "" && u.Host != "" {
|
||||
return u.Redacted()
|
||||
}
|
||||
if i := strings.Index(dsn, "password="); i >= 0 {
|
||||
rest := dsn[i+len("password="):]
|
||||
end := strings.IndexAny(rest, " \t")
|
||||
if end < 0 {
|
||||
end = len(rest)
|
||||
}
|
||||
return dsn[:i] + "password=***" + rest[end:]
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
|
||||
// Template 返回环境段式示例配置(dev/test/prod 三段,覆盖常用键)。
|
||||
func Template() []byte {
|
||||
tpl := map[string]any{
|
||||
"dev": map[string]any{
|
||||
"addr": "127.0.0.1:8787", "data": "./data-dev", "plugins": "./plugins", "mock": true,
|
||||
"settings": map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{
|
||||
"baseUrl": "https://api.openai.com/v1", "apiKey": "CHANGE_ME", "model": "gpt-4o-mini",
|
||||
}},
|
||||
},
|
||||
},
|
||||
"test": map[string]any{
|
||||
"addr": "127.0.0.1:8788", "data": "./data-test", "plugins": "./plugins",
|
||||
},
|
||||
"prod": map[string]any{
|
||||
"addr": "127.0.0.1:8787", "data": "/var/lib/agent-system", "plugins": "./plugins",
|
||||
"dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system?sslmode=disable",
|
||||
"auth": "auto",
|
||||
"settings": map[string]any{
|
||||
"runtime": map[string]any{"rateLimitPerMin": 300, "redisAddr": "127.0.0.1:6379"},
|
||||
},
|
||||
},
|
||||
}
|
||||
out, _ := json.MarshalIndent(tpl, "", " ")
|
||||
return append(out, '\n')
|
||||
}
|
||||
|
||||
// InitFile 写出示例配置(拒绝覆盖既有文件;权限 0600,配置可含密钥)。
|
||||
func InitFile(path string) error {
|
||||
if path == "" {
|
||||
return fmt.Errorf("config init: --out 不能为空")
|
||||
}
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return fmt.Errorf("config init: %s 已存在,拒绝覆盖", path)
|
||||
}
|
||||
if dir := filepath.Dir(path); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("config init: %w", err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(path, Template(), 0o600); err != nil {
|
||||
return fmt.Errorf("config init: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -660,3 +660,39 @@ func TestModelRoutingOverrideChain(t *testing.T) {
|
||||
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsAPIExposesSystemLayer:设置 API 呈现启动注入层(配置文件/CLI/环境
|
||||
// 经组合根 SetSystemOverride 注入),API 消费方可还原 defaults→system→user→project
|
||||
// 完整覆盖链;密钥脱敏与 user 层一致。
|
||||
func TestSettingsAPIExposesSystemLayer(t *testing.T) {
|
||||
srv, _ := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
srv.set.SetSystemOverride(map[string]any{
|
||||
"storage": map[string]any{"dataDir": "/srv/data"},
|
||||
"runtime": map[string]any{"mock": true},
|
||||
"models": map[string]any{"chat": map[string]any{"apiKey": "sk-boot-123", "model": "boot-model"}},
|
||||
})
|
||||
|
||||
code, out := doJSON(t, h, "GET", "/api/settings", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("get settings: %d", code)
|
||||
}
|
||||
sys, _ := out["system"].(map[string]any)
|
||||
if sys == nil {
|
||||
t.Fatalf("响应缺少 system 层: %v", out)
|
||||
}
|
||||
if got := sys["storage"].(map[string]any)["dataDir"]; got != "/srv/data" {
|
||||
t.Fatalf("system.storage.dataDir: %v", got)
|
||||
}
|
||||
if got := sys["models"].(map[string]any)["chat"].(map[string]any)["model"]; got != "boot-model" {
|
||||
t.Fatalf("system.models.chat.model: %v", got)
|
||||
}
|
||||
if got := sys["models"].(map[string]any)["chat"].(map[string]any)["apiKey"]; got != "••••••" {
|
||||
t.Fatalf("system 层密钥应脱敏: %v", got)
|
||||
}
|
||||
// 生效链:system 层在 user 层之下——user 未设置时生效值来自 system。
|
||||
eff := srv.set.Effective(store.OwnerDefault, "")
|
||||
if !eff.GetBool("runtime.mock", false) || eff.GetString("models.chat.model", "") != "boot-model" {
|
||||
t.Fatal("system 层应参与 Effective 计算")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,6 +582,7 @@ func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"schema": schema,
|
||||
"defaults": settings.Masked(core.EffectiveSettings(settings.SystemDefaults())),
|
||||
"system": maskMap(s.set.SystemLayer()), // 启动注入层(配置文件/CLI/环境;密钥脱敏)
|
||||
"user": maskMap(mustUser(s.set, owner)),
|
||||
"project": maskMap(mustScope(s.set, owner, projectID)),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 设置树覆盖校验:配置文件 settings 子树按本包 schema(4.9 权威)校验,
|
||||
// 未知键与类型错误定位到路径,防拼写静默失效(standards/configuration.md)。
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// ValidateTreeOverlay 校验设置树覆盖:叶路径须在 Schema 中且类型/枚举匹配。
|
||||
func ValidateTreeOverlay(tree map[string]any) error {
|
||||
schema := map[string]core.SettingDesc{}
|
||||
for _, d := range Schema() {
|
||||
schema[d.Key] = d
|
||||
}
|
||||
return validateOverlay("", tree, schema)
|
||||
}
|
||||
|
||||
func validateOverlay(prefix string, m map[string]any, schema map[string]core.SettingDesc) error {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
path := k
|
||||
if prefix != "" {
|
||||
path = prefix + "." + k
|
||||
}
|
||||
if sub, ok := m[k].(map[string]any); ok {
|
||||
if err := validateOverlay(path, sub, schema); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
d, known := schema[path]
|
||||
if !known {
|
||||
return fmt.Errorf("settings.%s: 未知设置项(不在 schema;防拼写错误)", path)
|
||||
}
|
||||
if err := checkValueType(d, m[k]); err != nil {
|
||||
return fmt.Errorf("settings.%s: %v", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkValueType(d core.SettingDesc, v any) error {
|
||||
switch d.Type {
|
||||
case core.SettingString, core.SettingSecret:
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("须为字符串(得到 %T)", v)
|
||||
}
|
||||
case core.SettingBool:
|
||||
if _, ok := v.(bool); !ok {
|
||||
return fmt.Errorf("须为布尔值(得到 %T)", v)
|
||||
}
|
||||
case core.SettingNumber:
|
||||
if _, ok := v.(float64); !ok {
|
||||
return fmt.Errorf("须为数字(得到 %T)", v)
|
||||
}
|
||||
case core.SettingEnum:
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("须为字符串(得到 %T)", v)
|
||||
}
|
||||
for _, o := range d.Options {
|
||||
if s == o {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("须为 %v 之一(得到 %q)", d.Options, s)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateTreeOverlay(t *testing.T) {
|
||||
ok := map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{"baseUrl": "https://x", "apiKey": "k", "model": "m"}},
|
||||
"runtime": map[string]any{"mock": true, "rateLimitPerMin": 300.0},
|
||||
"retrieval": map[string]any{"topK": 8.0},
|
||||
"ui": map[string]any{"theme": "light"},
|
||||
}
|
||||
if err := ValidateTreeOverlay(ok); err != nil {
|
||||
t.Fatalf("合法覆盖不应报错: %v", err)
|
||||
}
|
||||
bad := []struct {
|
||||
tree map[string]any
|
||||
wantPath string
|
||||
}{
|
||||
{map[string]any{"models": map[string]any{"chat": map[string]any{"basUrl": "x"}}}, "models.chat.basUrl"},
|
||||
{map[string]any{"runtime": map[string]any{"redisAddr": 6379.0}}, "runtime.redisAddr"},
|
||||
{map[string]any{"retrieval": map[string]any{"topK": "8"}}, "retrieval.topK"},
|
||||
{map[string]any{"runtime": map[string]any{"mock": "true"}}, "runtime.mock"},
|
||||
{map[string]any{"ui": map[string]any{"theme": "blue"}}, "ui.theme"},
|
||||
}
|
||||
for _, c := range bad {
|
||||
err := ValidateTreeOverlay(c.tree)
|
||||
if err == nil || !strings.Contains(err.Error(), c.wantPath) {
|
||||
t.Fatalf("应定位到 %s: %v", c.wantPath, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,15 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||||
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
|
||||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||||
|
||||
// SystemLayer 返回系统层覆盖(启动注入快照;nil 安全返回空 map)。
|
||||
// 设置 API 据此呈现完整覆盖链:defaults → system → user → project。
|
||||
func (s *Service) SystemLayer() map[string]any {
|
||||
if s.system == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return s.system
|
||||
}
|
||||
|
||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
|
||||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||||
|
||||
Reference in New Issue
Block a user