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
248 lines
6.1 KiB
Go
248 lines
6.1 KiB
Go
// 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
|
||
}
|