Skip to content

Commit 8ecaf0b

Browse files
committed
refactor(dependency): replace marshal-based env binding with type-walk
The previous approach marshalled a zero config value to YAML and loaded it into a scratch viper to discover keys. This missed nil-pointer optional sections (e.g. scheduler server.tls) and embedded squashed structs. Replace it with a recursive reflect.Type walk that follows mapstructure tags directly — the same tags viper.Unmarshal uses — handling pointer descent, `,squash` embedded structs, and untagged fields (decoded by field name). Tests are added for squashed and untagged field overrides. Signed-off-by: Gaius <gaius.qi@gmail.com>
1 parent 8d70dbd commit 8ecaf0b

2 files changed

Lines changed: 89 additions & 40 deletions

File tree

cmd/dependency/dependency.go

Lines changed: 47 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
package dependency
1818

1919
import (
20-
"bytes"
2120
"context"
2221
"errors"
2322
"fmt"
@@ -77,64 +76,74 @@ func InitCommandAndConfig(cmd *cobra.Command, useConfigFile bool, config any) {
7776
panic(fmt.Errorf("bind common flags to viper: %w", err))
7877
}
7978

80-
// Config for binding env
79+
// Env overrides: AutomaticEnv resolves env values for keys viper
80+
// already knows (config file, flags); bindEnvsFromConfig registers
81+
// every config struct key so overrides also reach keys absent from
82+
// the config file (spf13/viper#761).
8183
viper.SetEnvPrefix(rootName)
8284
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
8385
viper.AutomaticEnv()
84-
_ = viper.BindEnv("config")
85-
86-
// Bind env for keys absent from the config file
8786
bindEnvsFromConfig(config)
8887

88+
// "config" is a flag-only key, not part of the config struct.
89+
_ = viper.BindEnv("config")
90+
8991
// Add common cmds only on root cmd
9092
cmd.AddCommand(VersionCmd)
9193
cmd.AddCommand(newDocCommand(cmd.Name()))
9294
cmd.AddCommand(PluginCmd)
9395
}
9496
}
9597

96-
// bindEnvsFromConfig binds an env for every config key, so AutomaticEnv can
97-
// override keys that are not present in the config file.
98+
// bindEnvsFromConfig binds an env for every key in the config struct, so
99+
// AutomaticEnv can override keys that are not present in the config file.
100+
// Viper only applies env overrides in Unmarshal for keys it already knows
101+
// (spf13/viper#761), so each key must be bound explicitly.
98102
func bindEnvsFromConfig(config any) {
99-
schema := reflect.New(reflect.TypeOf(config).Elem()).Interface()
100-
materializeStructPtrs(reflect.ValueOf(schema).Elem())
101-
102-
b, err := yaml.Marshal(schema)
103-
if err != nil {
104-
panic(fmt.Errorf("marshal config for env binding: %w", err))
105-
}
103+
bindEnvs(reflect.TypeOf(config), "")
104+
}
106105

107-
keys := viper.New()
108-
keys.SetConfigType("yaml")
109-
if err := keys.ReadConfig(bytes.NewReader(b)); err != nil {
110-
panic(fmt.Errorf("read config for env binding: %w", err))
106+
// bindEnvs walks a config type over its mapstructure tags — the tags
107+
// viper.Unmarshal decodes with — and binds every leaf key. Walking the type
108+
// rather than a value also covers optional sections behind nil pointers
109+
// (e.g. the scheduler's server.tls), which a marshalled snapshot of the
110+
// default config would miss.
111+
func bindEnvs(t reflect.Type, prefix string) {
112+
// Descend through pointers (optional sections like server.tls).
113+
for t.Kind() == reflect.Pointer {
114+
t = t.Elem()
111115
}
112116

113-
for _, key := range keys.AllKeys() {
114-
_ = viper.BindEnv(key)
117+
if t.Kind() != reflect.Struct {
118+
_ = viper.BindEnv(prefix)
119+
return
115120
}
116-
}
117121

118-
// materializeStructPtrs allocates nil struct-pointers so their nested keys serialize.
119-
func materializeStructPtrs(v reflect.Value) {
120-
switch v.Kind() {
121-
case reflect.Pointer:
122-
if v.Type().Elem().Kind() != reflect.Struct {
123-
return
122+
for i := range t.NumField() {
123+
field := t.Field(i)
124+
if !field.IsExported() {
125+
continue
124126
}
125-
if v.IsNil() {
126-
if !v.CanSet() {
127-
return
128-
}
129-
v.Set(reflect.New(v.Type().Elem()))
127+
128+
name, opts, _ := strings.Cut(field.Tag.Get("mapstructure"), ",")
129+
switch {
130+
case name == "-":
131+
continue
132+
case field.Anonymous && strings.Contains(opts, "squash"):
133+
// mapstructure:",squash" flattens the embedded struct's
134+
// keys into the parent.
135+
bindEnvs(field.Type, prefix)
136+
continue
137+
case name == "":
138+
// Untagged fields decode by field name, matched
139+
// case-insensitively.
140+
name = field.Name
130141
}
131-
materializeStructPtrs(v.Elem())
132-
case reflect.Struct:
133-
for i := 0; i < v.NumField(); i++ {
134-
if f := v.Field(i); f.CanSet() {
135-
materializeStructPtrs(f)
136-
}
142+
143+
if prefix != "" {
144+
name = prefix + "." + name
137145
}
146+
bindEnvs(field.Type, name)
138147
}
139148
}
140149

cmd/dependency/dependency_test.go

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import (
2424
"github.com/stretchr/testify/assert"
2525
"github.com/stretchr/testify/require"
2626

27+
"d7y.io/dragonfly/v2/cmd/dependency/base"
2728
schedulerconfig "d7y.io/dragonfly/v2/scheduler/config"
2829
)
2930

@@ -34,11 +35,22 @@ type tlsConfig struct {
3435
CACert string `yaml:"caCert" mapstructure:"caCert"`
3536
}
3637

37-
// testConfig mirrors the real config shape: populated scalar defaults plus an
38-
// optional nested section behind a nil pointer.
38+
// portRange mirrors manager/config.TCPListenPortRange, whose fields carry no
39+
// tags and therefore decode by field name.
40+
type portRange struct {
41+
Start int
42+
End int
43+
}
44+
45+
// testConfig mirrors the real config shape: an embedded squashed section,
46+
// populated scalar defaults, an optional nested section behind a nil pointer,
47+
// and a section with untagged fields.
3948
type testConfig struct {
49+
base.Options `yaml:",inline" mapstructure:",squash"`
50+
4051
Name string `yaml:"name" mapstructure:"name"`
4152
TLS *tlsConfig `yaml:"tls" mapstructure:"tls"`
53+
Port portRange `yaml:"port" mapstructure:"port"`
4254
}
4355

4456
// newTestConfig returns the default config: scalar defaults set, optional TLS
@@ -96,6 +108,34 @@ func TestBindEnvsFromConfig_NoEnvKeepsDefaults(t *testing.T) {
96108
assert.Nil(t, cfg.TLS, "no env set should leave the optional section nil")
97109
}
98110

111+
// TestBindEnvsFromConfig_SquashedOverride covers embedded sections tagged
112+
// mapstructure:",squash" (base.Options in the real configs), whose keys live
113+
// at the top level rather than under the field name.
114+
func TestBindEnvsFromConfig_SquashedOverride(t *testing.T) {
115+
setupViper("test")
116+
t.Setenv("TEST_CONSOLE", "true")
117+
118+
cfg := newTestConfig()
119+
bindEnvsFromConfig(cfg)
120+
require.NoError(t, viper.Unmarshal(cfg, initDecoderConfig))
121+
122+
assert.True(t, cfg.Console, "squashed key should bind at the top level")
123+
}
124+
125+
// TestBindEnvsFromConfig_UntaggedFieldOverride covers fields without a
126+
// mapstructure tag (manager/config.TCPListenPortRange), which decode by field
127+
// name matched case-insensitively.
128+
func TestBindEnvsFromConfig_UntaggedFieldOverride(t *testing.T) {
129+
setupViper("test")
130+
t.Setenv("TEST_PORT_START", "65003")
131+
132+
cfg := newTestConfig()
133+
bindEnvsFromConfig(cfg)
134+
require.NoError(t, viper.Unmarshal(cfg, initDecoderConfig))
135+
136+
assert.Equal(t, 65003, cfg.Port.Start, "untagged field should bind by field name")
137+
}
138+
99139
// TestBindEnvsFromConfig_RealSchedulerConfig exercises the actual production
100140
// config type end-to-end, including a nested key (Server.TLS.CACert) that lives
101141
// under a pointer left nil by config.New() — the regression the fix targets.

0 commit comments

Comments
 (0)