You've already forked agentic-coding-workflow
feat(config): viper-based config loader with env + file + master key validation
This commit is contained in:
@@ -1,3 +1,26 @@
|
|||||||
module github.com/yan1h/agent-coding-workflow
|
module github.com/yan1h/agent-coding-workflow
|
||||||
|
|
||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
golang.org/x/sys v0.29.0 // indirect
|
||||||
|
golang.org/x/text v0.28.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
|
||||||
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Package config 提供基于 viper 的应用配置加载,按 默认值 → 配置文件 → 环境变量 的优先级合并。
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config 是应用全局配置的根结构。
|
||||||
|
type Config struct {
|
||||||
|
Env string `mapstructure:"env"`
|
||||||
|
DataDir string `mapstructure:"data_dir"`
|
||||||
|
MasterKey []byte // 解码后的 32 字节
|
||||||
|
HTTP HTTPConfig `mapstructure:"http"`
|
||||||
|
DB DBConfig `mapstructure:"db"`
|
||||||
|
Bootstrap BootstrapConfig `mapstructure:"bootstrap"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTPConfig 描述 HTTP 服务监听参数。
|
||||||
|
type HTTPConfig struct {
|
||||||
|
Addr string `mapstructure:"addr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DBConfig 描述数据库连接参数。
|
||||||
|
type DBConfig struct {
|
||||||
|
DSN string `mapstructure:"dsn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BootstrapConfig 描述首次启动注入的管理员账号。
|
||||||
|
type BootstrapConfig struct {
|
||||||
|
AdminEmail string `mapstructure:"admin_email"`
|
||||||
|
AdminPassword string `mapstructure:"admin_password"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 从(按优先级递增)默认值 → 配置文件(如有)→ 环境变量 加载配置。
|
||||||
|
// configFile 可为空字符串,仅用环境变量。
|
||||||
|
func Load(configFile string) (*Config, error) {
|
||||||
|
v := viper.New()
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
v.SetDefault("env", "development")
|
||||||
|
v.SetDefault("data_dir", "./data")
|
||||||
|
v.SetDefault("http.addr", ":8080")
|
||||||
|
|
||||||
|
if configFile != "" {
|
||||||
|
v.SetConfigFile(configFile)
|
||||||
|
if err := v.ReadInConfig(); err != nil {
|
||||||
|
return nil, fmt.Errorf("read config file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
v.SetEnvPrefix("APP")
|
||||||
|
v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||||
|
v.AutomaticEnv()
|
||||||
|
|
||||||
|
// 显式绑定无默认值的嵌套键,以便 Unmarshal 能从环境变量取值。
|
||||||
|
// (AutomaticEnv 仅对 v.Get* 直接查询生效;Unmarshal 只看 viper 已知的键集合。)
|
||||||
|
for _, key := range []string{
|
||||||
|
"db.dsn",
|
||||||
|
"bootstrap.admin_email",
|
||||||
|
"bootstrap.admin_password",
|
||||||
|
"master_key",
|
||||||
|
} {
|
||||||
|
if err := v.BindEnv(key); err != nil {
|
||||||
|
return nil, fmt.Errorf("bind env %s: %w", key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg Config
|
||||||
|
if err := v.Unmarshal(&cfg); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MasterKey 是 hex 字符串,需要单独解码
|
||||||
|
rawKey := v.GetString("master_key")
|
||||||
|
if rawKey == "" {
|
||||||
|
return nil, fmt.Errorf("APP_MASTER_KEY required")
|
||||||
|
}
|
||||||
|
key, err := hex.DecodeString(rawKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("APP_MASTER_KEY invalid hex: %w", err)
|
||||||
|
}
|
||||||
|
if len(key) != 32 {
|
||||||
|
return nil, fmt.Errorf("APP_MASTER_KEY must be 32 bytes (got %d)", len(key))
|
||||||
|
}
|
||||||
|
cfg.MasterKey = key
|
||||||
|
|
||||||
|
if cfg.DB.DSN == "" {
|
||||||
|
return nil, fmt.Errorf("APP_DB_DSN required")
|
||||||
|
}
|
||||||
|
return &cfg, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoad_FromEnv(t *testing.T) {
|
||||||
|
t.Setenv("APP_ENV", "test")
|
||||||
|
t.Setenv("APP_HTTP_ADDR", ":9090")
|
||||||
|
t.Setenv("APP_DB_DSN", "postgres://test")
|
||||||
|
t.Setenv("APP_MASTER_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff")
|
||||||
|
t.Setenv("APP_DATA_DIR", "/tmp/acw")
|
||||||
|
t.Setenv("APP_BOOTSTRAP_ADMIN_EMAIL", "a@b.c")
|
||||||
|
t.Setenv("APP_BOOTSTRAP_ADMIN_PASSWORD", "pw12345678")
|
||||||
|
|
||||||
|
cfg, err := Load("")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "test", cfg.Env)
|
||||||
|
require.Equal(t, ":9090", cfg.HTTP.Addr)
|
||||||
|
require.Equal(t, "postgres://test", cfg.DB.DSN)
|
||||||
|
require.Len(t, cfg.MasterKey, 32) // hex decoded → 32 bytes
|
||||||
|
require.Equal(t, "/tmp/acw", cfg.DataDir)
|
||||||
|
require.Equal(t, "a@b.c", cfg.Bootstrap.AdminEmail)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_MissingMasterKey(t *testing.T) {
|
||||||
|
os.Clearenv()
|
||||||
|
t.Setenv("APP_DB_DSN", "x")
|
||||||
|
_, err := Load("")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "MASTER_KEY")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoad_MasterKeyWrongLength(t *testing.T) {
|
||||||
|
t.Setenv("APP_MASTER_KEY", "deadbeef") // too short
|
||||||
|
t.Setenv("APP_DB_DSN", "x")
|
||||||
|
_, err := Load("")
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user