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
This commit is contained in:
@@ -11,3 +11,10 @@
|
||||
- `CGO_ENABLED=0 go build ./cmd/agent-system` — 单二进制(静态,内嵌前端后)。
|
||||
- 存储:嵌入式 SQLite(modernc.org/sqlite,纯 Go);FTS5 全文 + CJK LIKE 兜底。
|
||||
- 依赖:agent-system-core(deps submodule + replace)、agent-native(runtime/llm 复用)。
|
||||
|
||||
## 配置(启动层)
|
||||
|
||||
- 来源优先级:内置默认 → 显式配置文件(`--config <json>`,无自动发现)→ 环境变量(`AGENT_SYSTEM_ADDR/DATA/PLUGINS/DSN/ARTIFACTS/AUTH/MOCK`)→ 显式 CLI(未传入的 flag 不覆盖)。
|
||||
- 只读诊断:`agent-system config explain [--config …] [--<flag> …]`——逐字段打印解析值与来源,DSN 脱敏;不建库、不写目录。
|
||||
- 多实例:`envs/{dev,test,prod}.json` 为隔离示例(data 根/DB 不得重叠);实例约定详见 Documents 仓 `docs/proposals/2026-09-30-configuration-surface.md`。
|
||||
- 运行时业务设置(`/api/settings` user/project scope、profiles 路由)是独立的业务策略层,不参与启动合并。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// agent-system:单二进制服务(HTTP/SSE + 内嵌前端 + 双档存储)。
|
||||
// 部署约束(设计 4.1 原则 8):非容器化;拷贝即运行。
|
||||
// 嵌入式档默认(SQLite + 本地对象目录,零外部服务);--dsn 切换 PostgreSQL 档
|
||||
//(多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
|
||||
// (多用户/多实例,认证默认开启,apikey 子命令管理密钥)。
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"os"
|
||||
|
||||
"agent-system-backend/internal/app"
|
||||
"agent-system-backend/internal/config"
|
||||
)
|
||||
|
||||
var version = "0.1.0-dev"
|
||||
@@ -25,9 +26,12 @@ func main() {
|
||||
os.Args = append([]string{os.Args[0]}, os.Args[2:]...)
|
||||
case "apikey":
|
||||
os.Exit(runAPIKey(os.Args[2:]))
|
||||
case "config":
|
||||
os.Exit(runConfig(os.Args[2:]))
|
||||
}
|
||||
}
|
||||
fs := flag.NewFlagSet("serve", flag.ExitOnError)
|
||||
cfgFile := fs.String("config", "", "显式配置文件(JSON,键见 config explain;无自动发现)")
|
||||
addr := fs.String("addr", "127.0.0.1:8787", "监听地址")
|
||||
data := fs.String("data", "./data", "数据目录(SQLite 与工件对象目录)")
|
||||
pluginsDir := fs.String("plugins", "./plugins", "插件目录(声明资产热加载)")
|
||||
@@ -36,16 +40,56 @@ func main() {
|
||||
artifacts := fs.String("artifacts", "", "工件对象目录(默认 <data>/artifacts;PG 档多实例时指向共享卷)")
|
||||
auth := fs.String("auth", "auto", "API key 认证:auto(PG 档开、嵌入式关)| on | off")
|
||||
fs.Parse(os.Args[1:])
|
||||
cliSet := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
|
||||
|
||||
if err := app.Run(app.Options{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, ForceMock: *mock,
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := app.Run(app.Options{
|
||||
Addr: resolved.Addr, DataDir: resolved.DataDir, PluginsDir: resolved.PluginsDir, ForceMock: resolved.Mock,
|
||||
StorageDSN: resolved.StorageDSN, ArtifactsDir: resolved.ArtifactsDir, AuthMode: resolved.AuthMode,
|
||||
}); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// runConfig:config explain——只读解析诊断(不建库、不写业务目录;DSN 脱敏)。
|
||||
func runConfig(args []string) int {
|
||||
if len(args) == 0 || args[0] != "explain" {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system config explain [--config f.json] [--addr …](其余 serve 同名 flag 用于演示覆盖)")
|
||||
return 2
|
||||
}
|
||||
fs := flag.NewFlagSet("config explain", flag.ExitOnError)
|
||||
cfgFile := fs.String("config", "", "显式配置文件(JSON)")
|
||||
addr := fs.String("addr", "127.0.0.1:8787", "")
|
||||
data := fs.String("data", "./data", "")
|
||||
pluginsDir := fs.String("plugins", "./plugins", "")
|
||||
mock := fs.Bool("mock", false, "")
|
||||
dsn := fs.String("dsn", "", "")
|
||||
artifacts := fs.String("artifacts", "", "")
|
||||
auth := fs.String("auth", "auto", "")
|
||||
fs.Parse(args[1:])
|
||||
cliSet := map[string]bool{}
|
||||
fs.Visit(func(f *flag.Flag) { cliSet[f.Name] = true })
|
||||
resolved, err := config.Load(*cfgFile, os.LookupEnv, config.Config{
|
||||
Addr: *addr, DataDir: *data, PluginsDir: *pluginsDir, Mock: *mock,
|
||||
StorageDSN: *dsn, ArtifactsDir: *artifacts, AuthMode: *auth,
|
||||
}, cliSet)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "agent-system:", err)
|
||||
return 1
|
||||
}
|
||||
resolved.Explain(os.Stdout, *cfgFile)
|
||||
return 0
|
||||
}
|
||||
|
||||
// runAPIKey:密钥管理子命令(create/list/revoke)。
|
||||
func runAPIKey(args []string) int {
|
||||
// 子命令在前、flag 在后:先剥出子命令再解析(Go flag 遇位置参数即停)。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"addr": "127.0.0.1:8787",
|
||||
"data": "./data-dev",
|
||||
"mock": true
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"addr": "0.0.0.0:8787",
|
||||
"data": "/var/lib/agent-system",
|
||||
"dsn": "postgres://agent:CHANGE_ME@127.0.0.1:5432/agent_system",
|
||||
"artifacts": "/var/lib/agent-system-artifacts",
|
||||
"auth": "on"
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"addr": "127.0.0.1:8788",
|
||||
"data": "./data-test",
|
||||
"auth": "off"
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func noEnv(string) (string, bool) { return "", false }
|
||||
|
||||
func fileWith(t *testing.T, content string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "c.json")
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// 基线:无文件无环境无 CLI → 全默认。
|
||||
func TestDefaults(t *testing.T) {
|
||||
r, err := Load("", noEnv, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Addr != "127.0.0.1:8787" || r.DataDir != "./data" || r.AuthMode != "auto" || r.Mock {
|
||||
t.Fatalf("defaults wrong: %+v", r.Config)
|
||||
}
|
||||
if r.SourceOf("addr") != SrcDefault {
|
||||
t.Fatalf("addr source = %v", r.SourceOf("addr"))
|
||||
}
|
||||
}
|
||||
|
||||
// 四源优先级:文件 < 环境 < 显式 CLI。
|
||||
func TestPrecedence(t *testing.T) {
|
||||
f := fileWith(t, `{"addr":"127.0.0.1:5000","auth":"off"}`)
|
||||
env := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_ADDR" {
|
||||
return "127.0.0.1:6000", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
r, err := Load(f, env, Config{Addr: "127.0.0.1:7000", AuthMode: "on"}, map[string]bool{"addr": true, "auth": true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Addr != "127.0.0.1:7000" || r.SourceOf("addr") != SrcCLI {
|
||||
t.Fatalf("addr should be CLI: %q %v", r.Addr, r.SourceOf("addr"))
|
||||
}
|
||||
if r.AuthMode != "on" || r.SourceOf("auth") != SrcCLI {
|
||||
t.Fatalf("auth should be CLI: %q %v", r.AuthMode, r.SourceOf("auth"))
|
||||
}
|
||||
}
|
||||
|
||||
// 未显式传入的 CLI 字段不参与覆盖(区分"未设置"与零值)。
|
||||
func TestUnsetCLIDoesNotOverride(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_ADDR" {
|
||||
return "127.0.0.1:6000", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
// cli.Addr 是零值 "" 但未在 cliSet 中:不得覆盖环境值。
|
||||
r, err := Load("", env, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Addr != "127.0.0.1:6000" || r.SourceOf("addr") != SrcEnv {
|
||||
t.Fatalf("addr should stay env: %q %v", r.Addr, r.SourceOf("addr"))
|
||||
}
|
||||
}
|
||||
|
||||
// 文件层按键存在覆盖:mock:false 显式出现也生效。
|
||||
func TestFilePresenceSemantics(t *testing.T) {
|
||||
f := fileWith(t, `{"mock":true,"data":"./d2"}`)
|
||||
r, err := Load(f, noEnv, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Mock || r.DataDir != "./d2" || r.SourceOf("mock") != SrcFile {
|
||||
t.Fatalf("file layer wrong: %+v mock_src=%v", r.Config, r.SourceOf("mock"))
|
||||
}
|
||||
}
|
||||
|
||||
// 未知键:错误须定位字段名。
|
||||
func TestUnknownKey(t *testing.T) {
|
||||
f := fileWith(t, `{"bogus":1}`)
|
||||
_, err := Load(f, noEnv, Config{}, nil)
|
||||
if err == nil || !bytes.Contains([]byte(err.Error()), []byte(`unknown key "bogus"`)) {
|
||||
t.Fatalf("want unknown-key error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 显式传入而缺失:报错。
|
||||
func TestMissingFile(t *testing.T) {
|
||||
_, err := Load("/nonexistent/c.json", noEnv, Config{}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("missing file must error")
|
||||
}
|
||||
}
|
||||
|
||||
// 布尔环境变量:非法值报错,true/false/1/0 合法。
|
||||
func TestEnvBool(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_MOCK" {
|
||||
return "true", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
r, err := Load("", env, Config{}, nil)
|
||||
if err != nil || !r.Mock {
|
||||
t.Fatalf("env bool: %v %+v", err, r.Config)
|
||||
}
|
||||
bad := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_MOCK" {
|
||||
return "yes", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
if _, err := Load("", bad, Config{}, nil); err == nil {
|
||||
t.Fatal("invalid bool must error")
|
||||
}
|
||||
}
|
||||
|
||||
// explain:来源标注 + DSN 脱敏;只读(不落任何盘外文件)。
|
||||
func TestExplainRedactsDSN(t *testing.T) {
|
||||
env := func(k string) (string, bool) {
|
||||
if k == "AGENT_SYSTEM_DSN" {
|
||||
return "postgres://u:secret@h:5432/db", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
r, err := Load("", env, Config{}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
r.Explain(&b, "")
|
||||
out := b.String()
|
||||
if bytes.Contains([]byte(out), []byte("secret")) {
|
||||
t.Fatalf("dsn not redacted: %s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("u:***@h")) {
|
||||
t.Fatalf("redact form wrong: %s", out)
|
||||
}
|
||||
if !bytes.Contains([]byte(out), []byte("env")) {
|
||||
t.Fatalf("source column missing: %s", out)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user