Merge pull request 'U10b 核心一:字段级来源快照(反射分层 diff,取代键注册表)' (#13) from feature/u10b-field-sources-2026-09-29 into dev
This commit was merged in pull request #13.
This commit is contained in:
+26
-40
@@ -7,9 +7,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -219,8 +216,8 @@ func configCheck(args []string) {
|
|||||||
fmt.Println("config check: 通过")
|
fmt.Println("config check: 通过")
|
||||||
}
|
}
|
||||||
|
|
||||||
// configExplain 解释每个配置键的最终来源层(KTD17 第一片):
|
// configExplain 输出字段级来源表(KTD17:只报告来源/状态,不打印值)。
|
||||||
// cli > process-env > env-file > default;秘密值只显示 set 状态。
|
// 层序:cli > process-env > env-file(按加载顺序)> default。
|
||||||
func configExplain(args []string) {
|
func configExplain(args []string) {
|
||||||
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
|
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
|
||||||
envPath := fs.String("env", "", "环境配置文件路径(默认根 .env + configs/.env.{ENV} 叠加)")
|
envPath := fs.String("env", "", "环境配置文件路径(默认根 .env + configs/.env.{ENV} 叠加)")
|
||||||
@@ -229,44 +226,33 @@ func configExplain(args []string) {
|
|||||||
backend := fs.String("backend", "", "智能体后端覆盖")
|
backend := fs.String("backend", "", "智能体后端覆盖")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
var envFiles []string
|
cfg, src, err := config.LoadWithSources(*envPath)
|
||||||
if *envPath != "" {
|
if err != nil {
|
||||||
envFiles = append(envFiles, *envPath)
|
fmt.Fprintf(os.Stderr, "config explain: 加载失败: %v\n", err)
|
||||||
} else {
|
os.Exit(1)
|
||||||
envFiles = append(envFiles, ".env")
|
}
|
||||||
envName := "dev"
|
src, err = config.ApplyCLIOverrides(cfg, src, func(c *config.Config) {
|
||||||
if keys, err := config.ParseEnvFileKeys(".env"); err == nil {
|
if *port > 0 {
|
||||||
for _, k := range keys {
|
c.HTTPPort = *port
|
||||||
if k == "ENV" {
|
|
||||||
// 从文件行取值
|
|
||||||
if b, rerr := os.ReadFile(".env"); rerr == nil {
|
|
||||||
for _, line := range strings.Split(string(b), "\n") {
|
|
||||||
if strings.HasPrefix(strings.TrimSpace(line), "ENV=") {
|
|
||||||
envName = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(line), "ENV="))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if v := os.Getenv("ENV"); v != "" {
|
if *host != "" {
|
||||||
envName = v
|
c.HTTPHost = *host
|
||||||
}
|
}
|
||||||
envFiles = append(envFiles, filepath.Join("configs", ".env."+envName))
|
if *backend != "" {
|
||||||
|
c.AgentBackend = *backend
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "config explain: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := map[string]string{}
|
fmt.Printf("config explain: 字段 %d 个(层序 cli > process-env > env-file > default)\n", len(src))
|
||||||
if *port > 0 {
|
fmt.Print(config.FormatFieldSources(config.SortedFieldSources(src)))
|
||||||
cli["HTTP_PORT"] = strconv.Itoa(*port)
|
if issues := cfg.Check(); len(issues) > 0 {
|
||||||
|
fmt.Fprintln(os.Stderr, "附注:当前配置未通过 check:")
|
||||||
|
for _, it := range issues {
|
||||||
|
fmt.Fprintf(os.Stderr, " - %s\n", it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if *host != "" {
|
|
||||||
cli["HTTP_HOST"] = *host
|
|
||||||
}
|
|
||||||
if *backend != "" {
|
|
||||||
cli["AGENT_BACKEND"] = *backend
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Println("env files:", strings.Join(envFiles, ", "))
|
|
||||||
fmt.Print(config.FormatExplanation(config.Explain(envFiles, cli)))
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,20 @@ func DefaultDataDir() string {
|
|||||||
return "./data/dev"
|
return "./data/dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load 加载配置(行为与历史版本一致)。
|
||||||
func Load(path string) (*Config, error) {
|
func Load(path string) (*Config, error) {
|
||||||
|
cfg, _, err := LoadWithSources(path)
|
||||||
|
return cfg, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadWithSources 加载配置并给出字段级来源(KTD17 U10b)。
|
||||||
|
// 层序:default → env-file(按文件顺序)→ process-env;CLI 覆盖由调用方
|
||||||
|
// 在返回后应用并以 ApplyCLIOverrides 记录来源。
|
||||||
|
func LoadWithSources(path string) (*Config, map[string]FieldSource, error) {
|
||||||
|
var phases []struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
Env: "dev",
|
Env: "dev",
|
||||||
DataDir: DefaultDataDir(),
|
DataDir: DefaultDataDir(),
|
||||||
@@ -155,9 +168,18 @@ func Load(path string) (*Config, error) {
|
|||||||
MaxConcurrentAgents: 8,
|
MaxConcurrentAgents: 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
phases = append(phases, struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}{"default", snapshotFields(cfg)})
|
||||||
|
|
||||||
if path == "" {
|
if path == "" {
|
||||||
// 与 Compose 一致:根 .env(公共密钥)+ configs/.env.{ENV}(环境差异 overlay)
|
// 与 Compose 一致:根 .env(公共密钥)+ configs/.env.{ENV}(环境差异 overlay)
|
||||||
_ = applyEnvFile(cfg, ".env")
|
_ = applyEnvFile(cfg, ".env")
|
||||||
|
phases = append(phases, struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}{"env-file:.env", snapshotFields(cfg)})
|
||||||
envName := cfg.Env
|
envName := cfg.Env
|
||||||
if v := os.Getenv("ENV"); v != "" {
|
if v := os.Getenv("ENV"); v != "" {
|
||||||
envName = v
|
envName = v
|
||||||
@@ -165,9 +187,18 @@ func Load(path string) (*Config, error) {
|
|||||||
if envName == "" {
|
if envName == "" {
|
||||||
envName = "dev"
|
envName = "dev"
|
||||||
}
|
}
|
||||||
_ = applyEnvFile(cfg, filepath.Join("configs", ".env."+envName))
|
overlay := filepath.Join("configs", ".env."+envName)
|
||||||
|
_ = applyEnvFile(cfg, overlay)
|
||||||
|
phases = append(phases, struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}{"env-file:" + filepath.ToSlash(overlay), snapshotFields(cfg)})
|
||||||
} else {
|
} else {
|
||||||
_ = applyEnvFile(cfg, path)
|
_ = applyEnvFile(cfg, path)
|
||||||
|
phases = append(phases, struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}{"env-file:" + filepath.ToSlash(path), snapshotFields(cfg)})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 允许系统环境变量覆盖
|
// 允许系统环境变量覆盖
|
||||||
@@ -379,15 +410,42 @@ func Load(path string) (*Config, error) {
|
|||||||
applyDSNOverrides(cfg)
|
applyDSNOverrides(cfg)
|
||||||
applyCORSDefaults(cfg)
|
applyCORSDefaults(cfg)
|
||||||
NormalizeKBHostForRuntime(cfg)
|
NormalizeKBHostForRuntime(cfg)
|
||||||
|
phases = append(phases, struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}{"process-env", snapshotFields(cfg)})
|
||||||
|
|
||||||
if err := validateProductionSecrets(cfg); err != nil {
|
if err := validateProductionSecrets(cfg); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
if err := ValidateIndependentVault(cfg.VaultStateRoot, cfg.DataDir); err != nil {
|
if err := ValidateIndependentVault(cfg.VaultStateRoot, cfg.DataDir); err != nil {
|
||||||
return nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return cfg, nil
|
return cfg, diffLayers(phases), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyCLIOverrides 在 Load 之后应用显式 CLI 覆盖并更新字段来源层。
|
||||||
|
// 返回应用后的配置副本来源表(不修改原 cfg)。
|
||||||
|
func ApplyCLIOverrides(cfg *Config, src map[string]FieldSource, mutate func(*Config)) (map[string]FieldSource, error) {
|
||||||
|
if cfg == nil || mutate == nil {
|
||||||
|
return src, nil
|
||||||
|
}
|
||||||
|
clone := *cfg
|
||||||
|
before := snapshotFields(cfg)
|
||||||
|
mutate(&clone)
|
||||||
|
after := snapshotFields(&clone)
|
||||||
|
out := make(map[string]FieldSource, len(src))
|
||||||
|
for k, v := range src {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
for field, val := range after {
|
||||||
|
if before[field] != val {
|
||||||
|
fs := FieldSource{Field: field, Layer: "cli", Set: true}
|
||||||
|
out[field] = fs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// applyEnvFile overlays KEY=VALUE pairs from path onto cfg. Missing file is a no-op.
|
// applyEnvFile overlays KEY=VALUE pairs from path onto cfg. Missing file is a no-op.
|
||||||
|
|||||||
+14
-140
@@ -4,99 +4,10 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
// knownEnvKeys 是配置环境键注册表(U10b 第一片:reporting-only)。
|
|
||||||
// 仅用于 config explain 的来源解释,不参与取值——取值仍由 Load 的
|
|
||||||
// 应用逻辑唯一决定;新增环境键时须同步登记,漂移由抽样测试约束。
|
|
||||||
var knownEnvKeys = map[string]bool{
|
|
||||||
"ADMIN_PASSWORD": true,
|
|
||||||
"ADMIN_USERNAME": true,
|
|
||||||
"AGENTX_API_BASE": true,
|
|
||||||
"AGENTX_API_KEY": true,
|
|
||||||
"AGENTX_MODEL": true,
|
|
||||||
"AGENTX_TIMEOUT": true,
|
|
||||||
"AGENT_BACKEND": true,
|
|
||||||
"AGENT_TIMEOUT": true,
|
|
||||||
"ALLOW_IN_MEMORY": true,
|
|
||||||
"APP_DB_DSN": true,
|
|
||||||
"APP_PUBLISH_HOST": true,
|
|
||||||
"AUTH_REGISTRATION_ENABLED": true,
|
|
||||||
"AUTH_REQUIRE_APPROVAL": true,
|
|
||||||
"CONTEXT_DB_HOST": true,
|
|
||||||
"CONTEXT_DB_NAME": true,
|
|
||||||
"CONTEXT_DB_PASSWORD": true,
|
|
||||||
"CONTEXT_DB_PORT": true,
|
|
||||||
"CONTEXT_DB_USER": true,
|
|
||||||
"CORS_ALLOWED_ORIGINS": true,
|
|
||||||
"DATA_DIR": true,
|
|
||||||
"EMBEDDING_API_KEY": true,
|
|
||||||
"EMBEDDING_ENDPOINT_URL": true,
|
|
||||||
"EMBEDDING_MODEL": true,
|
|
||||||
"EMBEDDING_PROVIDER": true,
|
|
||||||
"ENV": true,
|
|
||||||
"FAV_DEGRADED_READ": true,
|
|
||||||
"FAV_PUBLISHER_ENABLED": true,
|
|
||||||
"HTTP_ADDR": true,
|
|
||||||
"HTTP_HOST": true,
|
|
||||||
"HTTP_PORT": true,
|
|
||||||
"JEV_API_KEY": true,
|
|
||||||
"JEV_BASE_URL": true,
|
|
||||||
"JEV_DECIDE_MODE": true,
|
|
||||||
"KB_DB_HOST": true,
|
|
||||||
"KB_DB_NAME": true,
|
|
||||||
"KB_DB_PASSWORD": true,
|
|
||||||
"KB_DB_PORT": true,
|
|
||||||
"KB_DB_USER": true,
|
|
||||||
"MATERIAL_FULLTEXT_MAX_BYTES": true,
|
|
||||||
"MATERIAL_RAG_TOP_K": true,
|
|
||||||
"MAX_CONCURRENT_AGENTS": true,
|
|
||||||
"MINERU_API_KEY": true,
|
|
||||||
"MINERU_ENDPOINT_URL": true,
|
|
||||||
"MINERU_LANGUAGE": true,
|
|
||||||
"MINERU_MODEL_VERSION": true,
|
|
||||||
"MINERU_TIMEOUT": true,
|
|
||||||
"MINIMAX_API_KEY": true,
|
|
||||||
"MINIMAX_ENDPOINT_URL": true,
|
|
||||||
"PI_AGENT_BIN": true,
|
|
||||||
"PI_AGENT_BRIDGE_BIN": true,
|
|
||||||
"PI_AGENT_MODEL": true,
|
|
||||||
"PI_AGENT_PROVIDER": true,
|
|
||||||
"PI_AGENT_RUNNER_AUTH_TOKEN": true,
|
|
||||||
"PI_AGENT_RUNNER_URL": true,
|
|
||||||
"POSTGRES_SHARED_KB_DSN": true,
|
|
||||||
"RERANKER_API_KEY": true,
|
|
||||||
"RERANKER_ENDPOINT_URL": true,
|
|
||||||
"RERANKER_MODEL": true,
|
|
||||||
"RERANKER_PROVIDER": true,
|
|
||||||
"RUNNER_AUTH_TOKEN": true,
|
|
||||||
"VAULT_MASTER_KEY": true,
|
|
||||||
"VAULT_STATE_ROOT": true,
|
|
||||||
"VAULT_STORAGE_IDENTITY": true,
|
|
||||||
"WORKSPACE_QUOTA_BYTES": true,
|
|
||||||
"WORKSPACE_TTL": true,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExplainLayer 是配置键的一个来源层。
|
|
||||||
type ExplainLayer = string
|
|
||||||
|
|
||||||
const (
|
|
||||||
LayerDefault ExplainLayer = "default"
|
|
||||||
LayerEnvFile ExplainLayer = "env-file"
|
|
||||||
LayerProcessEnv ExplainLayer = "process-env"
|
|
||||||
LayerCLI ExplainLayer = "cli"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ExplainEntry 是单个配置键的解释行。
|
|
||||||
type ExplainEntry struct {
|
|
||||||
Key string
|
|
||||||
Layer ExplainLayer
|
|
||||||
Value string // 秘密键只显示 <set>/<unset>;env-file 层显示文件路径
|
|
||||||
}
|
|
||||||
|
|
||||||
// ParseEnvFileKeys 用与 applyEnvFile 相同的解析规则列出文件中出现的键。
|
// ParseEnvFileKeys 用与 applyEnvFile 相同的解析规则列出文件中出现的键。
|
||||||
func ParseEnvFileKeys(path string) ([]string, error) {
|
func ParseEnvFileKeys(path string) ([]string, error) {
|
||||||
f, err := os.Open(path)
|
f, err := os.Open(path)
|
||||||
@@ -120,59 +31,22 @@ func ParseEnvFileKeys(path string) ([]string, error) {
|
|||||||
return keys, scanner.Err()
|
return keys, scanner.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Explain 生成各配置键的来源层解释(KTD17 config explain 第一片)。
|
// FormatFieldSources 渲染字段级来源表(KTD17:只报告来源/状态,不打印值)。
|
||||||
// envFiles 按加载顺序(后者覆盖前者);cliOverrides 为 CLI 显式覆盖键。
|
func FormatFieldSources(entries []FieldSource) string {
|
||||||
// 层序与生效优先级一致:cli > process-env > env-file > default。
|
|
||||||
// 非秘密且来源为 env-file/process-env 的键展示脱敏值;秘密键只显示 set 状态。
|
|
||||||
func Explain(envFiles []string, cliOverrides map[string]string) []ExplainEntry {
|
|
||||||
fileKeys := map[string]string{} // key -> 首个出现的文件
|
|
||||||
for _, p := range envFiles {
|
|
||||||
keys, err := ParseEnvFileKeys(p)
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
for _, k := range keys {
|
|
||||||
fileKeys[k] = filepath.ToSlash(p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var out []ExplainEntry
|
|
||||||
for k := range knownEnvKeys {
|
|
||||||
e := ExplainEntry{Key: k, Layer: LayerDefault, Value: "<unset>"}
|
|
||||||
if src, ok := fileKeys[k]; ok {
|
|
||||||
e.Layer = LayerEnvFile
|
|
||||||
e.Value = src
|
|
||||||
if v := os.Getenv(k); v != "" {
|
|
||||||
e.Layer = LayerProcessEnv
|
|
||||||
e.Value = maskedValue(v)
|
|
||||||
}
|
|
||||||
} else if v := os.Getenv(k); v != "" {
|
|
||||||
e.Layer = LayerProcessEnv
|
|
||||||
e.Value = maskedValue(v)
|
|
||||||
}
|
|
||||||
if v, ok := cliOverrides[k]; ok {
|
|
||||||
e.Layer = LayerCLI
|
|
||||||
e.Value = maskedValue(v)
|
|
||||||
}
|
|
||||||
out = append(out, e)
|
|
||||||
}
|
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func maskedValue(v string) string {
|
|
||||||
if strings.TrimSpace(v) == "" {
|
|
||||||
return "<unset>"
|
|
||||||
}
|
|
||||||
return "<set>"
|
|
||||||
}
|
|
||||||
|
|
||||||
// FormatExplanation 渲染 explain 表(供 CLI 输出与测试断言)。
|
|
||||||
func FormatExplanation(entries []ExplainEntry) string {
|
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
b.WriteString("key layer detail\n")
|
b.WriteString("field layer set\n")
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
fmt.Fprintf(&b, "%-37s %-12s %s\n", e.Key, e.Layer, e.Value)
|
fmt.Fprintf(&b, "%-37s %-19s %v\n", e.Field, e.Layer, e.Set)
|
||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SortedFieldSourceNames 返回已知来源字段名(测试辅助)。
|
||||||
|
func SortedFieldSourceNames(m map[string]FieldSource) []string {
|
||||||
|
names := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
names = append(names, k)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,44 +6,67 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 注册表键必须真实影响 Load(抽样防 reporting-only 注册表漂移)。
|
func TestFieldSourcesLayerAttribution(t *testing.T) {
|
||||||
func TestExplainRegistryKeysAreLive(t *testing.T) {
|
|
||||||
t.Setenv("HTTP_PORT", "9911")
|
|
||||||
t.Setenv("AGENT_BACKEND", "stub")
|
|
||||||
cfg, err := Load("")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("load: %v", err)
|
|
||||||
}
|
|
||||||
if cfg.HTTPPort != 9911 || cfg.AgentBackend != "stub" {
|
|
||||||
t.Fatalf("registry sample keys not applied: port=%d backend=%s", cfg.HTTPPort, cfg.AgentBackend)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExplainLayerAttribution(t *testing.T) {
|
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
envf := filepath.Join(dir, ".env")
|
envf := filepath.Join(dir, ".env")
|
||||||
if err := os.WriteFile(envf, []byte("HTTP_HOST=0.0.0.0\nKB_DB_PASSWORD=from-file\n"), 0o600); err != nil {
|
if err := os.WriteFile(envf, []byte("HTTP_HOST=0.0.0.0\nKB_DB_PASSWORD=from-file\nAGENT_BACKEND=stub\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file
|
t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file
|
||||||
entries := Explain([]string{envf}, map[string]string{"AGENT_BACKEND": "stub"})
|
|
||||||
by := map[string]ExplainEntry{}
|
cfg, src, err := LoadWithSources(envf)
|
||||||
for _, e := range entries {
|
if err != nil {
|
||||||
by[e.Key] = e
|
t.Fatalf("load: %v", err)
|
||||||
}
|
}
|
||||||
if by["HTTP_HOST"].Layer != LayerEnvFile {
|
if cfg.HTTPHost != "0.0.0.0" || cfg.KbDbPassword != "from-process" || cfg.AgentBackend != "stub" {
|
||||||
t.Fatalf("HTTP_HOST layer=%s want env-file", by["HTTP_HOST"].Layer)
|
t.Fatalf("values not applied: host=%s", cfg.HTTPHost)
|
||||||
}
|
}
|
||||||
if by["KB_DB_PASSWORD"].Layer != LayerProcessEnv {
|
if src["http_host"].Layer != "env-file:"+filepath.ToSlash(envf) {
|
||||||
t.Fatalf("KB_DB_PASSWORD layer=%s want process-env", by["KB_DB_PASSWORD"].Layer)
|
t.Fatalf("http_host layer=%s", src["http_host"].Layer)
|
||||||
}
|
}
|
||||||
if by["KB_DB_PASSWORD"].Value != "<set>" {
|
if src["kb_db_password"].Layer != "process-env" {
|
||||||
t.Fatalf("secret value leaked: %q", by["KB_DB_PASSWORD"].Value)
|
t.Fatalf("kb_db_password layer=%s", src["kb_db_password"].Layer)
|
||||||
}
|
}
|
||||||
if by["AGENT_BACKEND"].Layer != LayerCLI {
|
if src["agent_backend"].Layer != "env-file:"+filepath.ToSlash(envf) {
|
||||||
t.Fatalf("AGENT_BACKEND layer=%s want cli", by["AGENT_BACKEND"].Layer)
|
t.Fatalf("agent_backend layer=%s", src["agent_backend"].Layer)
|
||||||
}
|
}
|
||||||
if by["ADMIN_USERNAME"].Layer != LayerDefault {
|
// 未设置字段仍是 default 层
|
||||||
t.Fatalf("ADMIN_USERNAME layer=%s want default", by["ADMIN_USERNAME"].Layer)
|
if src["admin_username"].Layer != "default" || src["admin_username"].Set {
|
||||||
|
t.Fatalf("admin_username layer=%s set=%v", src["admin_username"].Layer, src["admin_username"].Set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyCLIOverridesAttribution(t *testing.T) {
|
||||||
|
cfg, src, err := LoadWithSources("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src2, err := ApplyCLIOverrides(cfg, src, func(c *Config) {
|
||||||
|
c.HTTPPort = 9999
|
||||||
|
c.AgentBackend = "stub"
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if src2["http_port"].Layer != "cli" || !src2["http_port"].Set {
|
||||||
|
t.Fatalf("http_port layer=%s set=%v", src2["http_port"].Layer, src2["http_port"].Set)
|
||||||
|
}
|
||||||
|
if src2["agent_backend"].Layer != "cli" {
|
||||||
|
t.Fatalf("agent_backend layer=%s", src2["agent_backend"].Layer)
|
||||||
|
}
|
||||||
|
// 未覆盖字段来源不变
|
||||||
|
if src2["http_host"].Layer != src["http_host"].Layer {
|
||||||
|
t.Fatalf("http_host layer changed unexpectedly")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 行为回归:老入口签名与结果不变。
|
||||||
|
func TestLoadStillWorks(t *testing.T) {
|
||||||
|
cfg, err := Load("")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if cfg.HTTPPort == 0 || cfg.DataDir == "" {
|
||||||
|
t.Fatal("defaults lost")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FieldSource 是单个配置字段的来源解释(KTD17:只报告来源/状态,不打印值)。
|
||||||
|
type FieldSource struct {
|
||||||
|
Field string // json 标签名(无标签时为小写字段名)
|
||||||
|
Layer string // default | env-file:<path> | process-env | cli
|
||||||
|
Set bool // 是否被显式设置(非默认值即视为被设置)
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapshotFields 用反射导出 Config 的全部字段值(格式化字符串)。
|
||||||
|
// 字段均为基本类型/时间片,无指针与嵌套结构;新增字段自动纳入。
|
||||||
|
func snapshotFields(c *Config) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
v := reflect.ValueOf(*c)
|
||||||
|
t := v.Type()
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
f := t.Field(i)
|
||||||
|
if f.PkgPath != "" {
|
||||||
|
continue // 未导出
|
||||||
|
}
|
||||||
|
name := f.Tag.Get("json")
|
||||||
|
if idx := len(name); idx > 0 {
|
||||||
|
// 去 omitempty 等选项
|
||||||
|
for j := 0; j < len(name); j++ {
|
||||||
|
if name[j] == ',' {
|
||||||
|
name = name[:j]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = lowerFirst(f.Name)
|
||||||
|
}
|
||||||
|
fv := v.Field(i)
|
||||||
|
var s string
|
||||||
|
if fv.Type() == reflect.TypeOf(time.Duration(0)) {
|
||||||
|
s = fv.Interface().(time.Duration).String()
|
||||||
|
} else {
|
||||||
|
s = fmt.Sprintf("%v", fv.Interface())
|
||||||
|
}
|
||||||
|
out[name] = s
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func lowerFirst(s string) string {
|
||||||
|
if s == "" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
b := []byte(s)
|
||||||
|
if b[0] >= 'A' && b[0] <= 'Z' {
|
||||||
|
b[0] += 'a' - 'A'
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// diffLayers 依序比较各阶段快照,返回每个字段的最后变更层(即生效来源)。
|
||||||
|
// phases 按应用顺序给出(层名, 快照);字段未出现于任何快照时归为 default。
|
||||||
|
func diffLayers(phases []struct {
|
||||||
|
Layer string
|
||||||
|
Snap map[string]string
|
||||||
|
}) map[string]FieldSource {
|
||||||
|
src := map[string]FieldSource{}
|
||||||
|
var prev map[string]string
|
||||||
|
for _, ph := range phases {
|
||||||
|
for field, val := range ph.Snap {
|
||||||
|
fs := src[field]
|
||||||
|
fs.Field = field
|
||||||
|
if prev == nil || prev[field] != val {
|
||||||
|
fs.Layer = ph.Layer
|
||||||
|
fs.Set = prev == nil || prev[field] != val && ph.Layer != phases[0].Layer
|
||||||
|
}
|
||||||
|
src[field] = fs
|
||||||
|
}
|
||||||
|
prev = ph.Snap
|
||||||
|
}
|
||||||
|
// default 层不算"显式设置"
|
||||||
|
for field, fs := range src {
|
||||||
|
if fs.Layer == phases[0].Layer {
|
||||||
|
fs.Set = false
|
||||||
|
src[field] = fs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return src
|
||||||
|
}
|
||||||
|
|
||||||
|
// SortedFieldSources 排序输出(稳定渲染用)。
|
||||||
|
func SortedFieldSources(m map[string]FieldSource) []FieldSource {
|
||||||
|
out := make([]FieldSource, 0, len(m))
|
||||||
|
for _, fs := range m {
|
||||||
|
out = append(out, fs)
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Field < out[j].Field })
|
||||||
|
return out
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user