Let an upgraded binary start against the settings it wrote before

The deployed service crash-looped 83 times on "config.toml:3: unknown setting
\"include_amount\"". The classification redesign retired that preference from
both the reader and the writer, but /var/lib/finance-duck/config.toml was
written by the previous binary and still names it, and Open refuses any key its
switch does not recognise. So the new binary would not start against its own
settings file: nixos-rebuild switched successfully, systemd restarted the unit
until it gave up, and the updater's health check failed - a deployment error
whose cause was neither the build nor the code that was deployed.

Retired settings are now read and discarded, and the next SaveSettings rewrites
the file without them. An unrecognised key is still refused, because a
misspelled preference that loads silently is a preference the user believes is
in force. Every future removal adds its key to the same list rather than
stranding the deployments that already hold it.

Verified by running the built binary against a config.toml carrying exactly the
line the host has: it starts and /api/health answers 200, where the previous
binary exited 1. The regression test fails with "retired setting must not stop
startup" before the change, and it still requires classify_on_imports to be
rejected.
This commit is contained in:
Lars Nolden
2026-09-11 23:50:38 +02:00
parent 266bfa6d6a
commit da817078f4
2 changed files with 49 additions and 1 deletions
+10 -1
View File
@@ -79,6 +79,13 @@ type App struct {
syncRequested chan struct{}
}
// Settings this application has retired. They are read and discarded: a
// config.toml written by an older binary must never stop the new one from
// starting, and the next SaveSettings rewrites the file without them. An
// unrecognised key is still refused, so a typo cannot silently lose a
// preference.
var retiredSettings = map[string]bool{"include_amount": true}
func Open(dir string) (*App, error) {
j, err := journal.Open(dir)
if err != nil {
@@ -115,7 +122,9 @@ func Open(dir string) (*App, error) {
case "classify_on_import":
a.settings.ClassifyOnImport, err = strconv.ParseBool(v)
default:
err = fmt.Errorf("unknown setting %q", k)
if !retiredSettings[k] {
err = fmt.Errorf("unknown setting %q", k)
}
}
if err != nil {
return fail(fmt.Errorf("config.toml:%d: %w", n+1, err))
+39
View File
@@ -7,6 +7,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
@@ -57,6 +59,43 @@ func seed(t *testing.T, a *App, s State) State {
}
return result.State
}
// A released binary wrote include_amount into config.toml. Refusing it on
// startup made every upgraded deployment crash-loop against its own settings
// file, so a retired key must load and then disappear on the next save.
func TestRetiredSettingLoadsAndIsRewrittenAwayButTyposStillFail(t *testing.T) {
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("ENABLEBANKING_APP_ID", "")
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("classification_model = \"old/model\"\ninclude_amount = true\nclassify_on_import = false\n"), 0600); err != nil {
t.Fatal(err)
}
a, err := Open(dir)
if err != nil {
t.Fatalf("retired setting must not stop startup: %v", err)
}
defer a.Close()
if a.settings.Model != "old/model" || a.settings.ClassifyOnImport {
t.Fatalf("surrounding settings lost: %#v", a.settings)
}
if _, err := a.SaveSettings(context.Background(), Settings{Model: "new/model", ClassifyOnImport: true}); err != nil {
t.Fatal(err)
}
written, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(written), "include_amount") {
t.Fatalf("retired setting survived a save: %s", written)
}
if err := os.WriteFile(path, []byte("classify_on_imports = true\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := Open(dir); err == nil {
t.Fatal("a misspelled setting must still be refused")
}
}
func TestFailedClassificationStillImportsAndRetryIsIdempotent(t *testing.T) {
a, s := testApp(t)
mock := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusServiceUnavailable) }))