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) + } +}