feat: validate configuration on startup, fail fast on bad config (closes #52) #53

Merged
sneak merged 11 commits from feature/config-validation into main 2026-08-07 22:39:40 +02:00
Showing only changes of commit c0d325156f - Show all commits

View File

@@ -132,10 +132,12 @@ func newFromSmartConfig(sc *smartconfig.Config) (*Config, error) {
// validateKnownKeys rejects configuration files containing keys the // validateKnownKeys rejects configuration files containing keys the
// application does not understand, so typos fail at startup instead of // application does not understand, so typos fail at startup instead of
// being silently ignored. The env section is permitted because // being silently ignored, and rejects keys that are explicitly set to
// null: a null is a SET value, never an omission, so it must not
// silently take the default. The env section is permitted because
// smartconfig consumes it for environment variable injection. // smartconfig consumes it for environment variable injection.
func validateKnownKeys(sc *smartconfig.Config) error { func validateKnownKeys(sc *smartconfig.Config) error {
var unknown []string var unknown, nullKeys []string
for key, value := range sc.Data() { for key, value := range sc.Data() {
if !isKnownConfigKey(key) { if !isKnownConfigKey(key) {
@@ -144,6 +146,12 @@ func validateKnownKeys(sc *smartconfig.Config) error {
continue continue
} }
if value == nil {
nullKeys = append(nullKeys, key)
continue
}
if key == "metrics" { if key == "metrics" {
metricsMap, ok := value.(map[string]interface{}) metricsMap, ok := value.(map[string]interface{})
if !ok { if !ok {
@@ -152,9 +160,15 @@ func validateKnownKeys(sc *smartconfig.Config) error {
"metrics", value) "metrics", value)
} }
for subkey := range metricsMap { for subkey, subvalue := range metricsMap {
if subkey != "username" && subkey != "password" { if subkey != "username" && subkey != "password" {
unknown = append(unknown, "metrics."+subkey) unknown = append(unknown, "metrics."+subkey)
continue
}
if subvalue == nil {
nullKeys = append(nullKeys, "metrics."+subkey)
} }
} }
} }
@@ -166,9 +180,29 @@ func validateKnownKeys(sc *smartconfig.Config) error {
return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", ")) return fmt.Errorf("unknown config keys: %s", strings.Join(unknown, ", "))
} }
if len(nullKeys) > 0 {
sort.Strings(nullKeys)
if len(nullKeys) == 1 {
return errNullConfigValue(nullKeys[0])
}
return fmt.Errorf(
"config keys %s: value is null; omit a key entirely to use its default",
strings.Join(nullKeys, ", "))
}
return nil return nil
} }
// errNullConfigValue reports a config key that is explicitly set to
// null (including the bare "key:" form and the "~" alias). Silently
// applying the default would mask a truncated or typo'd config entry.
func errNullConfigValue(key string) error {
return fmt.Errorf(
"config key %q: value is null; omit the key entirely to use the default", key)
}
// isKnownConfigKey reports whether key is a permitted top-level // isKnownConfigKey reports whether key is a permitted top-level
// configuration key. // configuration key.
func isKnownConfigKey(key string) bool { func isKnownConfigKey(key string) bool {
@@ -371,17 +405,22 @@ func (l *strictLoader) boolVal(key string, defaultVal bool) bool {
} }
// getString returns the string value for key, or defaultVal if the key // getString returns the string value for key, or defaultVal if the key
// is omitted. A present value that is not a string is an error. // is omitted. A present value that is not a string, or is explicitly
// null, is an error.
func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) { func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
if sc == nil { if sc == nil {
return defaultVal, nil return defaultVal, nil
} }
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return defaultVal, nil return defaultVal, nil
} }
if raw == nil {
return "", errNullConfigValue(key)
}
str, ok := raw.(string) str, ok := raw.(string)
if !ok { if !ok {
return "", fmt.Errorf("config key %q: value %v (%T) is not a string", return "", fmt.Errorf("config key %q: value %v (%T) is not a string",
@@ -392,18 +431,22 @@ func getString(sc *smartconfig.Config, key, defaultVal string) (string, error) {
} }
// getInt returns the integer value for key, or defaultVal if the key is // getInt returns the integer value for key, or defaultVal if the key is
// omitted. A present value that is not a whole number is an error; // omitted. A present value that is not a whole number, or is explicitly
// fractional values are never truncated. // null, is an error; fractional values are never truncated.
func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) { func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
if sc == nil { if sc == nil {
return defaultVal, nil return defaultVal, nil
} }
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return defaultVal, nil return defaultVal, nil
} }
if raw == nil {
return 0, errNullConfigValue(key)
}
switch val := raw.(type) { switch val := raw.(type) {
case int: case int:
return val, nil return val, nil
@@ -430,17 +473,22 @@ func getInt(sc *smartconfig.Config, key string, defaultVal int) (int, error) {
// getBool returns the boolean value for key, or defaultVal if the key // getBool returns the boolean value for key, or defaultVal if the key
// is omitted. A present value that is not a boolean (or a ParseBool-able // is omitted. A present value that is not a boolean (or a ParseBool-able
// string) is an error; numbers are not accepted as booleans. // string), or is explicitly null, is an error; numbers are not accepted
// as booleans.
func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) { func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error) {
if sc == nil { if sc == nil {
return defaultVal, nil return defaultVal, nil
} }
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return defaultVal, nil return defaultVal, nil
} }
if raw == nil {
return false, errNullConfigValue(key)
}
switch val := raw.(type) { switch val := raw.(type) {
case bool: case bool:
return val, nil return val, nil
@@ -459,17 +507,21 @@ func getBool(sc *smartconfig.Config, key string, defaultVal bool) (bool, error)
// validateAllowlistHostsValue checks the raw shape of the // validateAllowlistHostsValue checks the raw shape of the
// allowlist_hosts value before the lenient extraction in getStringSlice // allowlist_hosts value before the lenient extraction in getStringSlice
// runs: a value that is not a list of strings (or a comma-separated // runs: an explicitly null value, a value that is not a list of strings
// string), a non-string entry, or an empty entry is an error, never // (or a comma-separated string), a non-string entry, or an empty entry
// silently skipped. // is an error, never silently skipped.
func validateAllowlistHostsValue(sc *smartconfig.Config) error { func validateAllowlistHostsValue(sc *smartconfig.Config) error {
const key = "allowlist_hosts" const key = "allowlist_hosts"
raw, ok := sc.Get(key) raw, ok := sc.Get(key)
if !ok || raw == nil { if !ok {
return nil return nil
} }
if raw == nil {
return errNullConfigValue(key)
}
switch val := raw.(type) { switch val := raw.(type) {
case []interface{}: case []interface{}:
for _, item := range val { for _, item := range val {