feat(cli): 'config explain' with per-key layer attribution (U10b slice 1)
- config.Explain(envFiles, cliOverrides): per-key source layer following the application precedence cli > process-env > env-file > default; registry is reporting-only (values still applied solely by Load); secrets never shown - ParseEnvFileKeys shares applyEnvFile's parsing rules - cmd/server 'config explain' resolves the same env-file stack as Load and renders the attribution table; smoke: HTTP_PORT/AGENT_BACKEND -> cli - tests: registry liveness sample + layer attribution (file/process/cli/ default) + secret masking - full per-field snapshot with applied/desired revisions remains U10b core
This commit is contained in:
+65
-5
@@ -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)))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user