Skip to content

Commit 4d7ae60

Browse files
committed
feat: warn and continue on config type errors
1 parent 043cb73 commit 4d7ae60

4 files changed

Lines changed: 279 additions & 6 deletions

File tree

‎internal/config/auth_load_workers_test.go‎

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,141 @@
11
package config
22

33
import (
4+
"io"
45
"os"
56
"path/filepath"
7+
"reflect"
8+
"strings"
69
"testing"
10+
11+
log "github.com/sirupsen/logrus"
12+
logtest "github.com/sirupsen/logrus/hooks/test"
713
)
814

15+
func TestConfigLoadersWarnAndContinueOnTypeErrors(t *testing.T) {
16+
raw := []byte("port: 9001\n" +
17+
"oauth-excluded-models:\n" +
18+
" codex:\n" +
19+
" - valid-model\n" +
20+
" - name: invalid-model-secret\n" +
21+
"auth-load-workers: worker-secret\n" +
22+
"debug: true\n")
23+
24+
loaders := []struct {
25+
name string
26+
load func(*testing.T, []byte) (*Config, error)
27+
}{
28+
{
29+
name: "bytes",
30+
load: func(_ *testing.T, data []byte) (*Config, error) {
31+
return ParseConfigBytes(data)
32+
},
33+
},
34+
{
35+
name: "file",
36+
load: func(t *testing.T, data []byte) (*Config, error) {
37+
configPath := filepath.Join(t.TempDir(), "config.yaml")
38+
if errWrite := os.WriteFile(configPath, data, 0o600); errWrite != nil {
39+
t.Fatal(errWrite)
40+
}
41+
return LoadConfig(configPath)
42+
},
43+
},
44+
}
45+
46+
for _, loader := range loaders {
47+
t.Run(loader.name, func(t *testing.T) {
48+
hook := captureConfigWarnings(t)
49+
cfg, errLoad := loader.load(t, raw)
50+
if errLoad != nil {
51+
t.Fatalf("load config error = %v", errLoad)
52+
}
53+
if cfg.Port != 9001 || !cfg.Debug {
54+
t.Fatalf("valid config fields were not preserved: port=%d debug=%t", cfg.Port, cfg.Debug)
55+
}
56+
if cfg.AuthLoadWorkers != DefaultAuthLoadWorkers {
57+
t.Fatalf("AuthLoadWorkers = %d, want default %d", cfg.AuthLoadWorkers, DefaultAuthLoadWorkers)
58+
}
59+
if got := cfg.OAuthExcludedModels["codex"]; !reflect.DeepEqual(got, []string{"valid-model"}) {
60+
t.Fatalf("OAuthExcludedModels[codex] = %#v, want valid entry only", got)
61+
}
62+
63+
entries := hook.AllEntries()
64+
if len(entries) != 2 {
65+
t.Fatalf("warning count = %d, want 2", len(entries))
66+
}
67+
wantContent := map[int]string{
68+
5: "- name: <redacted>",
69+
6: "auth-load-workers: <redacted>",
70+
}
71+
wantDetail := map[int]string{
72+
5: "cannot unmarshal !!map into string",
73+
6: "cannot unmarshal !!str into int",
74+
}
75+
for _, entry := range entries {
76+
if entry.Level != log.WarnLevel || entry.Message != "ignoring invalid config value" {
77+
t.Fatalf("unexpected log entry: level=%s message=%q", entry.Level, entry.Message)
78+
}
79+
line, okLine := entry.Data["line"].(int)
80+
if !okLine {
81+
t.Fatalf("warning line = %#v, want int", entry.Data["line"])
82+
}
83+
content, okContent := entry.Data["content"].(string)
84+
if !okContent || content != wantContent[line] {
85+
t.Fatalf("warning content at line %d = %#v, want %q", line, entry.Data["content"], wantContent[line])
86+
}
87+
detail, okDetail := entry.Data["error"].(string)
88+
if !okDetail || detail != wantDetail[line] {
89+
t.Fatalf("warning error at line %d = %#v, want %q", line, entry.Data["error"], wantDetail[line])
90+
}
91+
for _, secret := range []string{"invalid-model-secret", "worker-secret", "worker-"} {
92+
if strings.Contains(content, secret) || strings.Contains(detail, secret) {
93+
t.Fatalf("warning leaked %q: content=%q error=%q", secret, content, detail)
94+
}
95+
}
96+
}
97+
})
98+
}
99+
}
100+
101+
func TestConfigLoadersRejectNonTypeMismatchYAMLErrors(t *testing.T) {
102+
tests := []struct {
103+
name string
104+
raw []byte
105+
}{
106+
{name: "malformed collection", raw: []byte("auth-load-workers: [\n")},
107+
{name: "duplicate key", raw: []byte("port: 9001\nport: 9002\n")},
108+
}
109+
for _, test := range tests {
110+
t.Run(test.name, func(t *testing.T) {
111+
if _, errParse := ParseConfigBytes(test.raw); errParse == nil {
112+
t.Fatal("ParseConfigBytes() error = nil")
113+
}
114+
configPath := filepath.Join(t.TempDir(), "config.yaml")
115+
if errWrite := os.WriteFile(configPath, test.raw, 0o600); errWrite != nil {
116+
t.Fatal(errWrite)
117+
}
118+
if _, errLoad := LoadConfig(configPath); errLoad == nil {
119+
t.Fatal("LoadConfig() error = nil")
120+
}
121+
})
122+
}
123+
}
124+
125+
func captureConfigWarnings(t *testing.T) *logtest.Hook {
126+
t.Helper()
127+
logger := log.StandardLogger()
128+
previousHooks := logger.ReplaceHooks(make(log.LevelHooks))
129+
previousOutput := logger.Out
130+
logger.SetOutput(io.Discard)
131+
hook := logtest.NewLocal(logger)
132+
t.Cleanup(func() {
133+
logger.ReplaceHooks(previousHooks)
134+
logger.SetOutput(previousOutput)
135+
})
136+
return hook
137+
}
138+
9139
func TestParseConfigBytesAuthLoadWorkers(t *testing.T) {
10140
tests := []struct {
11141
name string

‎internal/config/config_decode.go‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
package config
2+
3+
import (
4+
"bytes"
5+
"errors"
6+
"strconv"
7+
"strings"
8+
9+
log "github.com/sirupsen/logrus"
10+
"gopkg.in/yaml.v3"
11+
)
12+
13+
const maxConfigWarningContentRunes = 256
14+
15+
type configYAMLWarning struct {
16+
line int
17+
detail string
18+
}
19+
20+
// unmarshalConfigYAML keeps yaml.v3's partially decoded result for type mismatches.
21+
// Syntax and structural errors remain fatal because they do not produce a trustworthy document.
22+
func unmarshalConfigYAML(data []byte, cfg *Config) error {
23+
errUnmarshal := yaml.Unmarshal(data, cfg)
24+
if errUnmarshal == nil {
25+
return nil
26+
}
27+
28+
var typeError *yaml.TypeError
29+
if !errors.As(errUnmarshal, &typeError) {
30+
return errUnmarshal
31+
}
32+
33+
warnings := make([]configYAMLWarning, 0, len(typeError.Errors))
34+
for _, issue := range typeError.Errors {
35+
warning, okMismatch := configTypeMismatchWarning(issue)
36+
if !okMismatch {
37+
return errUnmarshal
38+
}
39+
warnings = append(warnings, warning)
40+
}
41+
for _, warning := range warnings {
42+
log.WithFields(log.Fields{
43+
"content": redactedConfigSourceLine(data, warning.line),
44+
"error": warning.detail,
45+
"line": warning.line,
46+
}).Warn("ignoring invalid config value")
47+
}
48+
return nil
49+
}
50+
51+
func configTypeMismatchWarning(issue string) (configYAMLWarning, bool) {
52+
line := 0
53+
detail := strings.TrimSpace(issue)
54+
if remainder, okPrefix := strings.CutPrefix(detail, "line "); okPrefix {
55+
if lineText, message, okLine := strings.Cut(remainder, ": "); okLine {
56+
if parsedLine, errLine := strconv.Atoi(lineText); errLine == nil {
57+
line = parsedLine
58+
detail = message
59+
}
60+
}
61+
}
62+
const prefix = "cannot unmarshal "
63+
if !strings.HasPrefix(detail, prefix) {
64+
return configYAMLWarning{}, false
65+
}
66+
sourceAndValue, target, okTarget := strings.Cut(strings.TrimPrefix(detail, prefix), " into ")
67+
if !okTarget {
68+
return configYAMLWarning{}, false
69+
}
70+
sourceType := sourceAndValue
71+
if valueAt := strings.IndexByte(sourceType, ' '); valueAt >= 0 {
72+
sourceType = sourceType[:valueAt]
73+
}
74+
return configYAMLWarning{
75+
line: line,
76+
detail: prefix + sourceType + " into " + target,
77+
}, true
78+
}
79+
80+
func redactedConfigSourceLine(data []byte, line int) string {
81+
lines := bytes.Split(data, []byte("\n"))
82+
if line < 1 || line > len(lines) {
83+
return "<unavailable>"
84+
}
85+
raw := strings.TrimSpace(strings.TrimSuffix(string(lines[line-1]), "\r"))
86+
if raw == "" {
87+
return "<empty>"
88+
}
89+
90+
var document yaml.Node
91+
if errParse := yaml.Unmarshal([]byte(raw), &document); errParse != nil {
92+
return "<redacted>"
93+
}
94+
redactConfigLogNode(&document, false)
95+
rendered, errMarshal := yaml.Marshal(&document)
96+
if errMarshal != nil {
97+
return "<redacted>"
98+
}
99+
content := strings.Join(strings.Fields(string(rendered)), " ")
100+
if content == "" {
101+
return "<empty>"
102+
}
103+
runes := []rune(content)
104+
if len(runes) > maxConfigWarningContentRunes {
105+
content = string(runes[:maxConfigWarningContentRunes]) + "..."
106+
}
107+
return content
108+
}
109+
110+
func redactConfigLogNode(node *yaml.Node, mappingKey bool) {
111+
if node == nil {
112+
return
113+
}
114+
node.Anchor = ""
115+
node.HeadComment = ""
116+
node.LineComment = ""
117+
node.FootComment = ""
118+
119+
switch node.Kind {
120+
case yaml.DocumentNode:
121+
for _, child := range node.Content {
122+
redactConfigLogNode(child, false)
123+
}
124+
case yaml.MappingNode:
125+
for index := 0; index+1 < len(node.Content); index += 2 {
126+
redactConfigLogNode(node.Content[index], true)
127+
redactConfigLogNode(node.Content[index+1], false)
128+
}
129+
case yaml.SequenceNode:
130+
for _, child := range node.Content {
131+
redactConfigLogNode(child, false)
132+
}
133+
case yaml.AliasNode:
134+
node.Value = "<redacted>"
135+
node.Alias = nil
136+
case yaml.ScalarNode:
137+
if mappingKey || node.ShortTag() == "!!null" {
138+
return
139+
}
140+
node.Tag = "!!str"
141+
node.Value = "<redacted>"
142+
node.Style = 0
143+
}
144+
}

‎internal/config/config_load.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"syscall"
1010

1111
log "github.com/sirupsen/logrus"
12-
"gopkg.in/yaml.v3"
1312
)
1413

1514
// LoadConfig reads a YAML configuration file from the given path,
@@ -28,7 +27,8 @@ func LoadConfig(configFile string) (*Config, error) {
2827

2928
// LoadConfigOptional reads YAML from configFile.
3029
// If optional is true and the file is missing, it returns an empty Config.
31-
// If optional is true and the file is empty or invalid, it returns an empty Config.
30+
// If optional is true and the file is empty or structurally invalid, it returns an empty Config.
31+
// Field type mismatches are always logged and skipped while valid values are retained.
3232
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
3333
// Read the entire configuration file into memory.
3434
data, err := os.ReadFile(configFile)
@@ -81,9 +81,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
8181
cfg.Pprof.Addr = DefaultPprofAddr
8282
cfg.AuthLoadWorkers = DefaultAuthLoadWorkers
8383
cfg.CredentialInFlight = DefaultCredentialInFlightConfig()
84-
if err = yaml.Unmarshal(data, &cfg); err != nil {
84+
if err = unmarshalConfigYAML(data, &cfg); err != nil {
8585
if optional {
86-
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
86+
// In cloud deploy mode, if YAML syntax or structure parsing fails, return empty config instead of error.
8787
cfgOptional := &Config{CredentialInFlight: DefaultCredentialInFlightConfig()}
8888
cfgOptional.normalizeAuthLoadWorkers()
8989
cfgOptional.NormalizePluginsConfig()

‎internal/config/parse.go‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66

77
log "github.com/sirupsen/logrus"
88
"golang.org/x/crypto/bcrypt"
9-
"gopkg.in/yaml.v3"
109
)
1110

1211
// ParseConfigBytes parses a YAML configuration payload into Config and applies the same
@@ -38,7 +37,7 @@ func ParseConfigBytes(data []byte) (*Config, error) {
3837
cfg.AuthLoadWorkers = DefaultAuthLoadWorkers
3938
cfg.CredentialInFlight = DefaultCredentialInFlightConfig()
4039

41-
if err := yaml.Unmarshal(data, &cfg); err != nil {
40+
if err := unmarshalConfigYAML(data, &cfg); err != nil {
4241
return nil, fmt.Errorf("parse config payload: %w", err)
4342
}
4443

0 commit comments

Comments
 (0)