From 529532a7db658269896e93b8c3ec9d7e52b93fb9 Mon Sep 17 00:00:00 2001 From: Spoked Date: Sun, 19 Jul 2026 22:32:53 -0500 Subject: [PATCH] feat(config): warn on config keys this version does not use MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Viper ignores unknown keys silently, so a setting removed or renamed in a past release stays in config.yaml looking live while having no effect. The value can be edited, the service restarted, and nothing changes — with no signal that the key is dead. Decodes a throwaway copy with mapstructure's ErrorUnused and logs the result. Warns rather than fails: rejecting unknown keys would break startup for anyone whose config still carries a retired setting. Example against a real config, which reports one removed key and two renamed ones the operator had not noticed: 'import' has invalid keys: max_import_connections 'rclone' has invalid keys: read_chunk_size, read_chunk_size_limit Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/config/manager.go | 22 +++++++++ internal/config/unknown_keys_test.go | 68 ++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+) create mode 100644 internal/config/unknown_keys_test.go diff --git a/internal/config/manager.go b/internal/config/manager.go index 379689128..fec514eee 100644 --- a/internal/config/manager.go +++ b/internal/config/manager.go @@ -11,6 +11,7 @@ import ( "sync" "time" + "github.com/go-viper/mapstructure/v2" "github.com/javi11/altmount/internal/utils" "github.com/javi11/nntppool/v4" "github.com/jinzhu/copier" @@ -1348,6 +1349,8 @@ func (m *Manager) ReloadConfig() error { return fmt.Errorf("error reading config file %s: %w", m.configFile, err) } + warnUnknownConfigKeys() + // Create default config and unmarshal into it config := DefaultConfig() if err := viper.Unmarshal(config); err != nil { @@ -1766,6 +1769,23 @@ func SaveToFile(config *Config, filename string) error { } // LoadConfig loads configuration from file and merges with defaults +// warnUnknownConfigKeys logs config keys that do not map to any field on Config. +// These are almost always settings removed or renamed in a past release: viper +// ignores them silently, so a stale key looks live while having no effect. +// +// This warns rather than fails — rejecting unknown keys would break upgrades for +// anyone whose config still carries a retired setting. +func warnUnknownConfigKeys() { + probe := DefaultConfig() + err := viper.Unmarshal(probe, func(dc *mapstructure.DecoderConfig) { + dc.ErrorUnused = true + }) + if err != nil { + slog.Warn("Configuration contains keys this version does not use; they have no effect and can be removed", + "detail", err) + } +} + func LoadConfig(configFile string) (*Config, error) { config := DefaultConfig() @@ -1808,6 +1828,8 @@ func LoadConfig(configFile string) (*Config, error) { } } + warnUnknownConfigKeys() + // Unmarshal the config if err := viper.Unmarshal(config); err != nil { return nil, fmt.Errorf("error unmarshaling config: %w", err) diff --git a/internal/config/unknown_keys_test.go b/internal/config/unknown_keys_test.go new file mode 100644 index 000000000..d5e44bc2d --- /dev/null +++ b/internal/config/unknown_keys_test.go @@ -0,0 +1,68 @@ +package config + +import ( + "bytes" + "log/slog" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" +) + +// captureWarn runs fn with slog routed to a buffer and returns what was logged. +func captureWarn(t *testing.T, fn func()) string { + t.Helper() + var buf bytes.Buffer + prev := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelWarn}))) + defer slog.SetDefault(prev) + fn() + return buf.String() +} + +func writeConfig(t *testing.T, body string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write config: %v", err) + } + return path +} + +func TestWarnUnknownConfigKeys_ReportsRetiredKey(t *testing.T) { + path := writeConfig(t, ` +import: + max_processor_workers: 4 + max_import_connections: 60 +`) + viper.Reset() + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + t.Fatalf("read config: %v", err) + } + + out := captureWarn(t, warnUnknownConfigKeys) + if !strings.Contains(out, "max_import_connections") { + t.Errorf("warning did not name the retired key; got: %s", out) + } +} + +func TestWarnUnknownConfigKeys_SilentOnValidConfig(t *testing.T) { + path := writeConfig(t, ` +import: + max_processor_workers: 4 + segment_sample_percentage: 25 +`) + viper.Reset() + viper.SetConfigFile(path) + if err := viper.ReadInConfig(); err != nil { + t.Fatalf("read config: %v", err) + } + + if out := captureWarn(t, warnUnknownConfigKeys); out != "" { + t.Errorf("expected no warning for a valid config, got: %s", out) + } +}