viper.Unmarshal 只解码 AllKeys() 返回的键,而 AllKeys() 只汇总 SetDefault、 配置文件和显式 BindEnv 三个来源。AutomaticEnv 仅能覆盖已在其中的键,无法引入 新键;能兜底的 viper_bind_struct 又被 build tag 排除(我们只用 -tags embed)。 因此任何「没有注册默认值、且不在 config.yaml 里」的配置项,其环境变量会被静默 丢弃。image_storage 的 endpoint/bucket/access_key_id/secret_access_key/ public_base_url 正属此列,于是纯环境变量部署落到最坏组合:IMAGE_STORAGE_ENABLED 生效使 Enabled=true,四个凭证却为空 → Active()=false → 异步生图接口整体 404, 运维看到的却是"凭证不完整"。deploy/docker-compose.yml 默认就是纯环境变量驱动, 且自动生成的 config.yaml 从不写 image_storage 段,必然踩中(见 #4458、#4542)。 同类缺口不止于此:github_oauth、google_oauth、dingtalk_connect 三组第三方登录 配置(含 client_secret)同样完全无法用环境变量设置。 - 为这些键注册零值默认,使其进入 AllKeys() 而可被环境变量覆盖。零值与"键缺失" 时的解码结果一致,故行为不变。 - sticky_escape_enabled 例外:它的实际默认是 true(靠 IsSet 守卫在解码后补上), 注册 false 会让 IsSet 恒真而永久关闭该特性,故直接注册 true。 - 启动告警补上 missing_keys 字段,指明到底哪个凭证为空。 - 新增反射守卫测试:Config 结构体上每个可由环境变量表达的字段都必须已注册默认值。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VHreE5pzCkSYz7J45fmd2Y
91 lines
2.6 KiB
Go
91 lines
2.6 KiB
Go
//go:build unit
|
|
|
|
package config
|
|
|
|
import (
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
// collectMapstructureKeys walks a config struct and returns every dotted key
|
|
// viper would need in order to populate it.
|
|
func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]string) {
|
|
for i := 0; i < t.NumField(); i++ {
|
|
field := t.Field(i)
|
|
if field.PkgPath != "" {
|
|
continue // unexported
|
|
}
|
|
tag := field.Tag.Get("mapstructure")
|
|
name, _, _ := strings.Cut(tag, ",")
|
|
if name == "-" {
|
|
continue
|
|
}
|
|
if name == "" {
|
|
name = strings.ToLower(field.Name)
|
|
}
|
|
key := name
|
|
if prefix != "" {
|
|
key = prefix + "." + name
|
|
}
|
|
|
|
ft := field.Type
|
|
for ft.Kind() == reflect.Ptr {
|
|
ft = ft.Elem()
|
|
}
|
|
if ft.Kind() == reflect.Struct {
|
|
collectMapstructureKeys(ft, key, out)
|
|
continue
|
|
}
|
|
if ft.Kind() == reflect.Map {
|
|
// A map cannot be expressed in a single environment variable, so it
|
|
// is out of scope here — such settings need a config file either way.
|
|
continue
|
|
}
|
|
out[strings.ToLower(key)] = ft.String()
|
|
}
|
|
}
|
|
|
|
// TestConfigKeysAreEnvReachable is the systemic guard behind the image_storage
|
|
// bug: viper.Unmarshal only decodes keys returned by AllKeys(), which unions
|
|
// SetDefault keys, config-file keys and explicit BindEnv keys. AutomaticEnv can
|
|
// override a key already in that union but never introduces one, and the
|
|
// viper_bind_struct escape hatch is compiled out (we build with -tags embed).
|
|
//
|
|
// So a Config field with no registered default is unreachable by environment
|
|
// variable whenever the deployment has no config.yaml containing it — the
|
|
// operator sets the variable, the loader discards it, and the feature behaves
|
|
// as if it were never configured. That is exactly how image_storage credentials
|
|
// were lost, silently disabling async image tasks for env-driven deployments.
|
|
//
|
|
// When this fails, register a zero-valued default in setEnvReachableDefaults
|
|
// for each reported key.
|
|
func TestConfigKeysAreEnvReachable(t *testing.T) {
|
|
bound := map[string]string{}
|
|
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
|
|
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
setDefaults()
|
|
registered := map[string]struct{}{}
|
|
for _, key := range viper.AllKeys() {
|
|
registered[key] = struct{}{}
|
|
}
|
|
|
|
var unreachable []string
|
|
for key, kind := range bound {
|
|
if _, ok := registered[key]; !ok {
|
|
unreachable = append(unreachable, key+" ("+kind+")")
|
|
}
|
|
}
|
|
sort.Strings(unreachable)
|
|
|
|
if len(unreachable) > 0 {
|
|
t.Fatalf("%d config keys have no default registered, so their environment variables are silently ignored:\n %s",
|
|
len(unreachable), strings.Join(unreachable, "\n "))
|
|
}
|
|
}
|