Add the startup configuration pipe with file and env layers plus explain
Why: startup had only defaults and CLI flags; dev/test/prod operation needs the standard baseline (explicit file, environment, explicit-only CLI) and a read-only diagnostic. Evidence: internal/config unit tests cover precedence, unset-CLI no-override, presence-based file semantics, unknown-key/missing-file errors, env bool parsing, explain source labelling and DSN redaction; full suite 12 packages pass; live run of config explain with envs/prod.json shows correct layer attribution and redaction. Impact: serve gains --config and AGENT_SYSTEM_* env overrides (behavior unchanged when neither is used); new config explain subcommand; envs/ examples and PROJECT.md configuration section. Refs: documents docs/proposals/2026-09-30-configuration-surface.md; standards/configuration.md
This commit is contained in:
@@ -11,3 +11,10 @@
|
|||||||
- `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。
|
- `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。
|
||||||
- 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。
|
- 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。
|
||||||
- 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。
|
- 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。
|
||||||
|
|
||||||
|
## 配置(启动层)
|
||||||
|
|
||||||
|
- 来源优先级:内置默认 → 显式配置文件(`--config <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。
|
||||||
|
- 只读诊断:`agent-system config explain [--config …] [--<flag> …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。
|
||||||
|
- 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。
|
||||||
|
- 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 双档存储)。
|
// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 双档存储)。
|
||||||
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
|
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
|
||||||
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
|
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
|
||||||
//(多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
|
// (多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
"agent-system-backend/internal/app"
|
"agent-system-backend/internal/app"
|
||||||
|
"agent-system-backend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "0.1.0-dev"
|
var version = "0.1.0-dev"
|
||||||
@@ -25,9 +26,12 @@ func main() {
|
|||||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||||
case "apikey":
|
case "apikey":
|
||||||
os.Exit(runAPIKey(os.Args[2:]))
|
os.Exit(runAPIKey(os.Args[2:]))
|
||||||
|
case "config":
|
||||||
|
os.Exit(runConfig(os.Args[2:]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||||
|
cfgFile := fs.String("config", "", "显式配置文件(JSON,键见 config explain;无自动发现)")
|
||||||
addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
|
addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
|
||||||
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
|
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
|
||||||
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
|
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
|
||||||
@@ -36,16 +40,56 @@ func main() {
|
|||||||
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
||||||
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
||||||
fs.Parse(os.Args[1:])
|
fs.Parse(os.Args[1:])
|
||||||
|
cliSet := map[string]bool{}
|
||||||
|
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
|
||||||
|
|
||||||
if err := app.Run(app.Options{
|
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock,
|
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
||||||
|
}, cliSet)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := app.Run(app.Options{
|
||||||
|
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||||
|
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。
|
||||||
|
func runConfig(args []string) int {
|
||||||
|
if len(args) == 0 || args[0] != "explain" {
|
||||||
|
fmt.Fprintln(os.Stderr, "用法:agent-system config explain [--config f.json] [--addr …](其余 serve 同名 flag 用于演示覆盖)")
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
|
||||||
|
cfgFile := fs.String("config", "", "显式配置文件(JSON)")
|
||||||
|
addr := fs.String("addr", "127.0.0.1:8787", "")
|
||||||
|
data := fs.String("data", "./data", "")
|
||||||
|
pluginsDir := fs.String("plugins", "./plugins", "")
|
||||||
|
mock := fs.Bool("mock", false, "")
|
||||||
|
dsn := fs.String("dsn", "", "")
|
||||||
|
artifacts := fs.String("artifacts", "", "")
|
||||||
|
auth := fs.String("auth", "auto", "")
|
||||||
|
fs.Parse(args[1:])
|
||||||
|
cliSet := map[string]bool{}
|
||||||
|
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
|
||||||
|
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||||
|
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||||
|
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
||||||
|
}, cliSet)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
resolved.Explain(os.Stdout, *cfgFile)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// runAPIKey:密钥管理子命令(create/list/revoke)。
|
// runAPIKey:密钥管理子命令(create/list/revoke)。
|
||||||
func runAPIKey(args []string) int {
|
func runAPIKey(args []string) int {
|
||||||
// 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。
|
// 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"addr": "127.0.0.1:8787",
|
||||||
|
"data": "./data-dev",
|
||||||
|
"mock": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"addr": "0.0.0.0:8787",
|
||||||
|
"data": "/var/lib/agent-system",
|
||||||
|
"dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system",
|
||||||
|
"artifacts": "/var/lib/agent-system-artifacts",
|
||||||
|
"auth": "on"
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"addr": "127.0.0.1:8788",
|
||||||
|
"data": "./data-test",
|
||||||
|
"auth": "off"
|
||||||
|
}
|
||||||
@@ -0,0 +1,247 @@
|
|||||||
|
// Package config 提供 agent-system 启动层配置的单一解析管道:
|
||||||
|
// 内置默认 → 显式配置文件 → 环境变量 → 显式 CLI。
|
||||||
|
// 规范依据:Development-Guidelines standards/configuration.md(单一解析管道/启动依赖);
|
||||||
|
// 运行时业务设置(/api/settings)是独立的"产品业务策略"层,不在此合并。
|
||||||
|
// 只读诊断见 Resolved.Explain:不建数据库、不写业务目录,DSN 脱敏输出。
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 启动部署连接类字段(与 app.Options 一一对应)。
|
||||||
|
type Config struct {
|
||||||
|
Addr string `json:"addr"`
|
||||||
|
DataDir string `json:"data"`
|
||||||
|
PluginsDir string `json:"plugins"`
|
||||||
|
StorageDSN string `json:"dsn"`
|
||||||
|
ArtifactsDir string `json:"artifacts"`
|
||||||
|
AuthMode string `json:"auth"`
|
||||||
|
Mock bool `json:"mock"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var defaults = Config{
|
||||||
|
Addr: "127.0.0.1:8787",
|
||||||
|
DataDir: "./data",
|
||||||
|
PluginsDir: "./plugins",
|
||||||
|
AuthMode: "auto",
|
||||||
|
}
|
||||||
|
|
||||||
|
// envKey 字段 → 环境变量名(AGENT_SYSTEM_ 前缀)。
|
||||||
|
var envKey = map[string]string{
|
||||||
|
"addr": "AGENT_SYSTEM_ADDR",
|
||||||
|
"data": "AGENT_SYSTEM_DATA",
|
||||||
|
"plugins": "AGENT_SYSTEM_PLUGINS",
|
||||||
|
"dsn": "AGENT_SYSTEM_DSN",
|
||||||
|
"artifacts": "AGENT_SYSTEM_ARTIFACTS",
|
||||||
|
"auth": "AGENT_SYSTEM_AUTH",
|
||||||
|
"mock": "AGENT_SYSTEM_MOCK",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source 配置来源层级(数值越大优先级越高)。
|
||||||
|
type Source int
|
||||||
|
|
||||||
|
const (
|
||||||
|
SrcDefault Source = iota
|
||||||
|
SrcFile
|
||||||
|
SrcEnv
|
||||||
|
SrcCLI
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s Source) String() string {
|
||||||
|
switch s {
|
||||||
|
case SrcFile:
|
||||||
|
return "file"
|
||||||
|
case SrcEnv:
|
||||||
|
return "env"
|
||||||
|
case SrcCLI:
|
||||||
|
return "cli"
|
||||||
|
}
|
||||||
|
return "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved 解析结果:配置快照 + 每字段来源(供 explain 与测试断言)。
|
||||||
|
type Resolved struct {
|
||||||
|
Config
|
||||||
|
sources map[string]Source
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceOf 返回字段来源;未知字段名返回 SrcDefault。
|
||||||
|
func (r Resolved) SourceOf(field string) Source { return r.sources[field] }
|
||||||
|
|
||||||
|
// Load 合并四源。file 为空表示无配置文件层;非空而缺失/未知键/类型错误均为错误,
|
||||||
|
// 错误信息须定位到字段。cliSet 记录显式传入的 CLI 字段(flag.Visit 语义):
|
||||||
|
// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。
|
||||||
|
func Load(file string, envLookup func(string) (string, bool), cli Config, cliSet map[string]bool) (Resolved, error) {
|
||||||
|
cfg := defaults
|
||||||
|
src := map[string]Source{}
|
||||||
|
for f := range envKey {
|
||||||
|
src[f] = SrcDefault
|
||||||
|
}
|
||||||
|
if file != "" {
|
||||||
|
fc, present, err := loadFile(file)
|
||||||
|
if err != nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
// 文件层按"键存在"覆盖(区分未出现与显式 false/空串;空值有效性由字段语义判定)。
|
||||||
|
for f := range present {
|
||||||
|
if f == "mock" {
|
||||||
|
cfg.Mock = fc.Mock
|
||||||
|
} else {
|
||||||
|
setString(&cfg, f, stringField(fc, f))
|
||||||
|
}
|
||||||
|
src[f] = SrcFile
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 环境层:变量存在即参与覆盖(空串按显式空值处理,字段 schema 自行判定)。
|
||||||
|
for f, key := range envKey {
|
||||||
|
if v, ok := envLookup(key); ok {
|
||||||
|
if f == "mock" {
|
||||||
|
b, err := parseBool(key, v)
|
||||||
|
if err != nil {
|
||||||
|
return Resolved{}, err
|
||||||
|
}
|
||||||
|
cfg.Mock = b
|
||||||
|
} else {
|
||||||
|
setString(&cfg, f, v)
|
||||||
|
}
|
||||||
|
src[f] = SrcEnv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// CLI 层:仅显式传入。
|
||||||
|
for f := range cliSet {
|
||||||
|
src[f] = SrcCLI
|
||||||
|
if f == "mock" {
|
||||||
|
cfg.Mock = cli.Mock
|
||||||
|
} else {
|
||||||
|
setString(&cfg, f, stringField(cli, f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Resolved{Config: cfg, sources: src}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadFile(path string) (Config, map[string]bool, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, nil, fmt.Errorf("config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
var m map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &m); err != nil {
|
||||||
|
return Config{}, nil, fmt.Errorf("config file %s: %w", path, err)
|
||||||
|
}
|
||||||
|
present := map[string]bool{}
|
||||||
|
var c Config
|
||||||
|
for k, v := range m {
|
||||||
|
var dst any
|
||||||
|
switch k {
|
||||||
|
case "addr":
|
||||||
|
dst = &c.Addr
|
||||||
|
case "data":
|
||||||
|
dst = &c.DataDir
|
||||||
|
case "plugins":
|
||||||
|
dst = &c.PluginsDir
|
||||||
|
case "dsn":
|
||||||
|
dst = &c.StorageDSN
|
||||||
|
case "artifacts":
|
||||||
|
dst = &c.ArtifactsDir
|
||||||
|
case "auth":
|
||||||
|
dst = &c.AuthMode
|
||||||
|
case "mock":
|
||||||
|
dst = &c.Mock
|
||||||
|
default:
|
||||||
|
return Config{}, nil, fmt.Errorf("config file %s: unknown key %q", path, k)
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(v, dst); err != nil {
|
||||||
|
return Config{}, nil, fmt.Errorf("config file %s: key %q: %w", path, k, err)
|
||||||
|
}
|
||||||
|
present[k] = true
|
||||||
|
}
|
||||||
|
return c, present, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func stringField(c Config, f string) string {
|
||||||
|
switch f {
|
||||||
|
case "addr":
|
||||||
|
return c.Addr
|
||||||
|
case "data":
|
||||||
|
return c.DataDir
|
||||||
|
case "plugins":
|
||||||
|
return c.PluginsDir
|
||||||
|
case "dsn":
|
||||||
|
return c.StorageDSN
|
||||||
|
case "artifacts":
|
||||||
|
return c.ArtifactsDir
|
||||||
|
case "auth":
|
||||||
|
return c.AuthMode
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func setString(c *Config, f, v string) {
|
||||||
|
switch f {
|
||||||
|
case "addr":
|
||||||
|
c.Addr = v
|
||||||
|
case "data":
|
||||||
|
c.DataDir = v
|
||||||
|
case "plugins":
|
||||||
|
c.PluginsDir = v
|
||||||
|
case "dsn":
|
||||||
|
c.StorageDSN = v
|
||||||
|
case "artifacts":
|
||||||
|
c.ArtifactsDir = v
|
||||||
|
case "auth":
|
||||||
|
c.AuthMode = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseBool(name, v string) (bool, error) {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(v)) {
|
||||||
|
case "true", "1":
|
||||||
|
return true, nil
|
||||||
|
case "false", "0":
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("env %s: invalid bool %q", name, v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explain 只读诊断:逐字段打印解析值与来源层级,DSN 脱敏。
|
||||||
|
func (r Resolved) Explain(w io.Writer, file string) {
|
||||||
|
if file != "" {
|
||||||
|
fmt.Fprintf(w, "config file: %s\n", file)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(w, "config file: (none)")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "field value source")
|
||||||
|
for _, f := range []string{"addr", "data", "plugins", "dsn", "artifacts", "auth", "mock"} {
|
||||||
|
v := stringField(r.Config, f)
|
||||||
|
if f == "mock" {
|
||||||
|
v = fmt.Sprintf("%t", r.Mock)
|
||||||
|
}
|
||||||
|
if f == "dsn" {
|
||||||
|
v = redact(v)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%-10s %-56s %s\n", f, v, r.sources[f])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// redact 脱敏 DSN:scheme://user:password@… → scheme://user:***@…。
|
||||||
|
func redact(dsn string) string {
|
||||||
|
i := strings.Index(dsn, "://")
|
||||||
|
if i < 0 {
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
rest := dsn[i+3:]
|
||||||
|
at := strings.Index(rest, "@")
|
||||||
|
if at < 0 {
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
userpart := rest[:at]
|
||||||
|
if c := strings.Index(userpart, ":"); c >= 0 {
|
||||||
|
return dsn[:i+3] + userpart[:c] + ":***" + rest[at:]
|
||||||
|
}
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func noEnv(string) (string, bool) { return "", false }
|
||||||
|
|
||||||
|
func fileWith(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
p := filepath.Join(t.TempDir(), "c.json")
|
||||||
|
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基线:无文件无环境无 CLI → 全默认。
|
||||||
|
func TestDefaults(t *testing.T) {
|
||||||
|
r, err := Load("", noEnv, Config{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.Addr != "127.0.0.1:8787" || r.DataDir != "./data" || r.AuthMode != "auto" || r.Mock {
|
||||||
|
t.Fatalf("defaults wrong: %+v", r.Config)
|
||||||
|
}
|
||||||
|
if r.SourceOf("addr") != SrcDefault {
|
||||||
|
t.Fatalf("addr source = %v", r.SourceOf("addr"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 四源优先级:文件 < 环境 < 显式 CLI。
|
||||||
|
func TestPrecedence(t *testing.T) {
|
||||||
|
f := fileWith(t, `{"addr":"127.0.0.1:5000","auth":"off"}`)
|
||||||
|
env := func(k string) (string, bool) {
|
||||||
|
if k == "AGENT_SYSTEM_ADDR" {
|
||||||
|
return "127.0.0.1:6000", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
r, err := Load(f, env, Config{Addr: "127.0.0.1:7000", AuthMode: "on"}, map[string]bool{"addr": true, "auth": true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.Addr != "127.0.0.1:7000" || r.SourceOf("addr") != SrcCLI {
|
||||||
|
t.Fatalf("addr should be CLI: %q %v", r.Addr, r.SourceOf("addr"))
|
||||||
|
}
|
||||||
|
if r.AuthMode != "on" || r.SourceOf("auth") != SrcCLI {
|
||||||
|
t.Fatalf("auth should be CLI: %q %v", r.AuthMode, r.SourceOf("auth"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。
|
||||||
|
func TestUnsetCLIDoesNotOverride(t *testing.T) {
|
||||||
|
env := func(k string) (string, bool) {
|
||||||
|
if k == "AGENT_SYSTEM_ADDR" {
|
||||||
|
return "127.0.0.1:6000", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
// cli.Addr 是零值 "" 但未在 cliSet 中:不得覆盖环境值。
|
||||||
|
r, err := Load("", env, Config{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if r.Addr != "127.0.0.1:6000" || r.SourceOf("addr") != SrcEnv {
|
||||||
|
t.Fatalf("addr should stay env: %q %v", r.Addr, r.SourceOf("addr"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件层按键存在覆盖:mock:false 显式出现也生效。
|
||||||
|
func TestFilePresenceSemantics(t *testing.T) {
|
||||||
|
f := fileWith(t, `{"mock":true,"data":"./d2"}`)
|
||||||
|
r, err := Load(f, noEnv, Config{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !r.Mock || r.DataDir != "./d2" || r.SourceOf("mock") != SrcFile {
|
||||||
|
t.Fatalf("file layer wrong: %+v mock_src=%v", r.Config, r.SourceOf("mock"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未知键:错误须定位字段名。
|
||||||
|
func TestUnknownKey(t *testing.T) {
|
||||||
|
f := fileWith(t, `{"bogus":1}`)
|
||||||
|
_, err := Load(f, noEnv, Config{}, nil)
|
||||||
|
if err == nil || !bytes.Contains([]byte(err.Error()), []byte(`unknown key "bogus"`)) {
|
||||||
|
t.Fatalf("want unknown-key error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显式传入而缺失:报错。
|
||||||
|
func TestMissingFile(t *testing.T) {
|
||||||
|
_, err := Load("/nonexistent/c.json", noEnv, Config{}, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("missing file must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 布尔环境变量:非法值报错,true/false/1/0 合法。
|
||||||
|
func TestEnvBool(t *testing.T) {
|
||||||
|
env := func(k string) (string, bool) {
|
||||||
|
if k == "AGENT_SYSTEM_MOCK" {
|
||||||
|
return "true", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
r, err := Load("", env, Config{}, nil)
|
||||||
|
if err != nil || !r.Mock {
|
||||||
|
t.Fatalf("env bool: %v %+v", err, r.Config)
|
||||||
|
}
|
||||||
|
bad := func(k string) (string, bool) {
|
||||||
|
if k == "AGENT_SYSTEM_MOCK" {
|
||||||
|
return "yes", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if _, err := Load("", bad, Config{}, nil); err == nil {
|
||||||
|
t.Fatal("invalid bool must error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。
|
||||||
|
func TestExplainRedactsDSN(t *testing.T) {
|
||||||
|
env := func(k string) (string, bool) {
|
||||||
|
if k == "AGENT_SYSTEM_DSN" {
|
||||||
|
return "postgres://u:secret@h:5432/db", true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
r, err := Load("", env, Config{}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
r.Explain(&b, "")
|
||||||
|
out := b.String()
|
||||||
|
if bytes.Contains([]byte(out), []byte("secret")) {
|
||||||
|
t.Fatalf("dsn not redacted: %s", out)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(out), []byte("u:***@h")) {
|
||||||
|
t.Fatalf("redact form wrong: %s", out)
|
||||||
|
}
|
||||||
|
if !bytes.Contains([]byte(out), []byte("env")) {
|
||||||
|
t.Fatalf("source column missing: %s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user