Files
Agent-System-Backend/internal/config/config.go
T
weichen 66b4bbf55a 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
2026-09-30 04:19:11 +00:00

248 lines
6.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package config 提供 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
}