U10b 首切片:config explain 键级来源溯源(cli > process-env > env-file > default) #12

Merged
weichen merged 1 commits from feature/u10b-config-explain-2026-09-29 into dev 2026-09-29 07:44:39 +00:00
3 changed files with 292 additions and 5 deletions
Showing only changes of commit 679028e44e - Show all commits
+65 -5
View File
@@ -7,6 +7,9 @@ import (
"net/http"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
@@ -23,11 +26,17 @@ func main() {
if len(os.Args) > 1 {
switch os.Args[1] {
case "config":
if len(os.Args) > 2 && os.Args[2] == "check" {
configCheck(os.Args[3:])
return
if len(os.Args) > 2 {
switch os.Args[2] {
case "check":
configCheck(os.Args[3:])
return
case "explain":
configExplain(os.Args[3:])
return
}
}
fmt.Fprintln(os.Stderr, "用法: server config check [--env path] [--port N] [--host H] [--backend name]")
fmt.Fprintln(os.Stderr, "用法: server config check|explain [--env path] [--port N] [--host H] [--backend name]")
os.Exit(2)
case "-h", "--help", "help":
fmt.Println("用法: server [serve flags] 启动服务(默认)\n server config check [flags] 加载并校验配置(脱敏摘要,无副作用)")
@@ -174,7 +183,6 @@ func shutdownDrainTimeout(cfg *config.Config) time.Duration {
return at + 30*time.Second
}
// configCheck 加载并校验配置:输出脱敏摘要与问题清单。
// 不创建 DB、不启动服务、不写业务目录(KTD17 config check 约束)。
func configCheck(args []string) {
@@ -210,3 +218,55 @@ func configCheck(args []string) {
}
fmt.Println("config check: 通过")
}
// configExplain 解释每个配置键的最终来源层(KTD17 第一片):
// cli > process-env > env-file > default;秘密值只显示 set 状态。
func configExplain(args []string) {
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
envPath := fs.String("env", "", "环境配置文件路径(默认根 .env + configs/.env.{ENV} 叠加)")
port := fs.Int("port", 0, "HTTP 端口覆盖")
host := fs.String("host", "", "HTTP 主机覆盖")
backend := fs.String("backend", "", "智能体后端覆盖")
_ = fs.Parse(args)
var envFiles []string
if *envPath != "" {
envFiles = append(envFiles, *envPath)
} else {
envFiles = append(envFiles, ".env")
envName := "dev"
if keys, err := config.ParseEnvFileKeys(".env"); err == nil {
for _, k := range keys {
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 != "" {
envName = v
}
envFiles = append(envFiles, filepath.Join("configs", ".env."+envName))
}
cli := map[string]string{}
if *port > 0 {
cli["HTTP_PORT"] = strconv.Itoa(*port)
}
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)))
}
+178
View File
@@ -0,0 +1,178 @@
package config
import (
"bufio"
"fmt"
"os"
"path/filepath"
"sort"
"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 相同的解析规则列出文件中出现的键。
func ParseEnvFileKeys(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var keys []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
continue
}
keys = append(keys, strings.TrimSpace(parts[0]))
}
return keys, scanner.Err()
}
// Explain 生成各配置键的来源层解释(KTD17 config explain 第一片)。
// envFiles 按加载顺序(后者覆盖前者);cliOverrides 为 CLI 显式覆盖键。
// 层序与生效优先级一致: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
b.WriteString("key layer detail\n")
for _, e := range entries {
fmt.Fprintf(&b, "%-37s %-12s %s\n", e.Key, e.Layer, e.Value)
}
return b.String()
}
+49
View File
@@ -0,0 +1,49 @@
package config
import (
"os"
"path/filepath"
"testing"
)
// 注册表键必须真实影响 Load(抽样防 reporting-only 注册表漂移)。
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()
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 {
t.Fatal(err)
}
t.Setenv("KB_DB_PASSWORD", "from-process") // 进程 env 覆盖 env-file
entries := Explain([]string{envf}, map[string]string{"AGENT_BACKEND": "stub"})
by := map[string]ExplainEntry{}
for _, e := range entries {
by[e.Key] = e
}
if by["HTTP_HOST"].Layer != LayerEnvFile {
t.Fatalf("HTTP_HOST layer=%s want env-file", by["HTTP_HOST"].Layer)
}
if by["KB_DB_PASSWORD"].Layer != LayerProcessEnv {
t.Fatalf("KB_DB_PASSWORD layer=%s want process-env", by["KB_DB_PASSWORD"].Layer)
}
if by["KB_DB_PASSWORD"].Value != "<set>" {
t.Fatalf("secret value leaked: %q", by["KB_DB_PASSWORD"].Value)
}
if by["AGENT_BACKEND"].Layer != LayerCLI {
t.Fatalf("AGENT_BACKEND layer=%s want cli", by["AGENT_BACKEND"].Layer)
}
if by["ADMIN_USERNAME"].Layer != LayerDefault {
t.Fatalf("ADMIN_USERNAME layer=%s want default", by["ADMIN_USERNAME"].Layer)
}
}