From 70685528b8e635a9d7bf7b3fe878d99b6d29444b Mon Sep 17 00:00:00 2001 From: Fredrik Ahlgren Date: Tue, 8 Sep 2026 11:33:44 +0200 Subject: [PATCH] fix(config): do not start setup over live Settings A missing leftover config.yaml is not first-run when state.db already holds settings/config_v1. Load recovers from the sibling database and InitializeStorage rewrites the locator. An edited leftover seed or a wizard document cannot replace live Settings; only an old-Core rollback save is imported. Closes #1153 Signed-off-by: Fredrik Ahlgren --- .changeset/config-seed-not-clobber.md | 8 ++ go/internal/config/config.go | 5 + go/internal/config/storage.go | 81 ++++++++++++++-- go/internal/config/storage_test.go | 130 ++++++++++++++++++++++++++ 4 files changed, 218 insertions(+), 6 deletions(-) create mode 100644 .changeset/config-seed-not-clobber.md diff --git a/.changeset/config-seed-not-clobber.md b/.changeset/config-seed-not-clobber.md new file mode 100644 index 00000000..a7228d6b --- /dev/null +++ b/.changeset/config-seed-not-clobber.md @@ -0,0 +1,8 @@ +--- +"ftw": patch +--- + +A missing leftover config.yaml no longer starts the setup wizard over live +Settings. Core reloads settings/config_v1 from the sibling state.db and rewrites +the locator YAML. An edited leftover seed or a wizard document cannot replace +Settings already stored in SQLite; only an old-Core rollback save is imported. diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 339cb235..143bfd52 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -1500,6 +1500,11 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { func Load(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { + if errors.Is(err, os.ErrNotExist) { + if cfg, ok := loadSettingsBesideSeed(path); ok { + return cfg, nil + } + } return nil, fmt.Errorf("read %s: %w", path, err) } var source struct { diff --git a/go/internal/config/storage.go b/go/internal/config/storage.go index b5edc402..68351d17 100644 --- a/go/internal/config/storage.go +++ b/go/internal/config/storage.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "crypto/sha256" "encoding/json" "errors" @@ -50,6 +51,20 @@ func loadStored(database, baseDir string) (*Config, error) { return decodeStored(doc, database, baseDir) } +// loadSettingsBesideSeed reads live Settings from state.db next to the seed. +// Schema 2 stores Settings in SQLite; a missing leftover YAML is not first-run. +func loadSettingsBesideSeed(seedPath string) (*Config, bool) { + database, err := filepath.Abs(filepath.Join(filepath.Dir(seedPath), "state.db")) + if err != nil { + return nil, false + } + cfg, err := loadStored(database, filepath.Dir(seedPath)) + if err != nil { + return nil, false + } + return cfg, true +} + // InitializeStorage imports YAML once, then records the database location in // the seed file. If a crash interrupted this last step, reuse the committed // document instead of importing the old YAML again. A recovered database must @@ -77,6 +92,14 @@ func InitializeStorage(path, database string, cfg *Config, st *state.Store) (*Co if !reflect.DeepEqual(cfg, current) { return nil, errors.New("database recovery changed current settings; restore a full backup") } + if _, err := os.Lstat(path); err != nil { + if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("stat config seed: %w", err) + } + if err := writeSettingsLocator(path, database, current, nil); err != nil { + return nil, err + } + } return cfg, nil } rawSeed, readErr := os.ReadFile(path) @@ -96,12 +119,16 @@ func InitializeStorage(path, database string, cfg *Config, st *state.Store) (*Co // A rolled-back Core removes the unknown locator when it saves YAML. // Distinguish that new save from an import interrupted before publication: // the latter still has the exact source bytes committed with the document. - legacySave := found && saved.YAMLSourceHash != "" && sourceHash != "" && saved.YAMLSourceHash != sourceHash + // A wizard or leftover seed is not that save — keep live Settings. + legacySave := found && saved.YAMLSourceHash != "" && sourceHash != "" && saved.YAMLSourceHash != sourceHash && legacyCoreSave(rawSeed) if found && !legacySave { cfg, err = decodeStored(doc, database, filepath.Dir(path)) if err != nil { return nil, err } + if wizardOrDefaultSeed(rawSeed) { + rawSeed = nil + } } else { if cfg.EVCharger != nil { password, _, err := st.ConfigValue("ev_charger_password") @@ -122,16 +149,56 @@ func InitializeStorage(path, database string, cfg *Config, st *state.Store) (*Co return nil, err } } - // Preserve fields understood by the old Core so an automatic image rollback - // can still read its original settings. The new Core only reads the locator. + if err := writeSettingsLocator(path, database, cfg, rawSeed); err != nil { + return nil, err + } + return cfg, nil +} + +func writeSettingsLocator(path, database string, cfg *Config, rawSeed []byte) error { seed := *cfg if relative, err := filepath.Rel(filepath.Dir(path), database); err == nil { seed.ConfigDatabase = relative } if err := recordSettingsDatabase(path, &seed, rawSeed); err != nil { - return nil, fmt.Errorf("record settings database: %w", err) + return fmt.Errorf("record settings database: %w", err) } - return cfg, nil + return nil +} + +// leftoverSeedHeader is written onto the import seed so operators and an older +// Core can tell the file is a locator, not live Settings. +const leftoverSeedHeader = "# Settings live in SQLite. Use FTW Settings to change them.\n# This file keeps the original import for an older Core after rollback.\n" + +func legacyCoreSave(raw []byte) bool { + if len(raw) == 0 || bytes.Contains(raw, []byte("Settings live in SQLite")) { + return false + } + var source struct { + Database string `yaml:"config_database"` + } + if err := yaml.Unmarshal(raw, &source); err != nil || source.Database != "" { + return false + } + return !wizardOrDefaultSeed(raw) +} + +func wizardOrDefaultSeed(raw []byte) bool { + if len(raw) == 0 { + return false + } + var probe struct { + Site struct { + ControlIntervalS int `yaml:"control_interval_s"` + SlewRateW float64 `yaml:"slew_rate_w"` + MinDispatchIntervalS int `yaml:"min_dispatch_interval_s"` + } `yaml:"site"` + } + if err := yaml.Unmarshal(raw, &probe); err != nil { + return false + } + // setup.js buildConfig hardcodes these; applyDefaults uses 2 / 3000 / 2. + return probe.Site.ControlIntervalS == 5 && probe.Site.SlewRateW == 500 && probe.Site.MinDispatchIntervalS == 5 } func SaveStored(st *state.Store, path string, cfg *Config) error { @@ -245,7 +312,9 @@ func recordSettingsDatabase(path string, cfg *Config, raw []byte) error { if err != nil { return err } - data = append([]byte("# Settings live in SQLite. Use FTW Settings to change them.\n# This file keeps the original import for an older Core after rollback.\n"), data...) + if !bytes.Contains(data, []byte("Settings live in SQLite")) { + data = append([]byte(leftoverSeedHeader), data...) + } return writeConfigAtomic(defaultDurableWriter, path, data) } diff --git a/go/internal/config/storage_test.go b/go/internal/config/storage_test.go index 7674b4de..d8c3e844 100644 --- a/go/internal/config/storage_test.go +++ b/go/internal/config/storage_test.go @@ -231,6 +231,136 @@ func TestInterruptedImportReusesCommittedConfig(t *testing.T) { } } +func importedLiveSettings(t *testing.T) (path, database string, st *state.Store) { + t.Helper() + dir := t.TempDir() + path, database = filepath.Join(dir, "config.yaml"), filepath.Join(dir, "state.db") + if err := os.WriteFile(path, []byte(minimalYAML), 0600); err != nil { + t.Fatal(err) + } + cfg, err := Load(path) + if err != nil { + t.Fatal(err) + } + st, err = state.Open(database) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + cfg, err = InitializeStorage(path, database, cfg, st) + if err != nil { + t.Fatal(err) + } + cfg.Site.Name = "Live Settings" + if err := SaveStored(st, path, cfg); err != nil { + t.Fatal(err) + } + return path, database, st +} + +func TestMissingSeedReloadsLiveSettingsAndRewritesLocator(t *testing.T) { + path, database, st := importedLiveSettings(t) + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatalf("missing seed started setup over live Settings: %v", err) + } + if loaded.Site.Name != "Live Settings" { + t.Fatalf("missing seed ignored SQLite: %q", loaded.Site.Name) + } + got, err := InitializeStorage(path, database, loaded, st) + if err != nil { + t.Fatal(err) + } + if got.Site.Name != "Live Settings" || got.Revision < 2 { + t.Fatalf("missing seed clobbered Settings: site=%q revision=%d", got.Site.Name, got.Revision) + } + raw, err := os.ReadFile(path) + if err != nil || !strings.Contains(string(raw), "config_database:") { + t.Fatalf("locator not rewritten: %s %v", raw, err) + } + reloaded, err := Load(path) + if err != nil || reloaded.Site.Name != "Live Settings" { + t.Fatalf("rewritten locator lost Settings: %v %v", reloaded, err) + } +} + +func TestMissingSeedWithoutSettingsStillFailsLoad(t *testing.T) { + dir := t.TempDir() + path, database := filepath.Join(dir, "config.yaml"), filepath.Join(dir, "state.db") + st, err := state.Open(database) + if err != nil { + t.Fatal(err) + } + if err := st.Close(); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil { + t.Fatal("empty sibling database hid first-run setup") + } +} + +func TestEditedLeftoverSeedDoesNotOverrideLiveSettings(t *testing.T) { + path, database, st := importedLiveSettings(t) + edited := "site:\n name: Edited leftover\nfuse:\n max_amps: 63\n# Settings live in SQLite. Use FTW Settings to change them.\n" + if err := os.WriteFile(path, []byte(edited), 0600); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + got, err := InitializeStorage(path, database, loaded, st) + if err != nil { + t.Fatal(err) + } + if got.Site.Name != "Live Settings" || got.Fuse.MaxAmps != 16 { + t.Fatalf("edited leftover overwrote Settings: site=%q amps=%v", got.Site.Name, got.Fuse.MaxAmps) + } + reloaded, err := Load(path) + if err != nil || reloaded.Site.Name != "Live Settings" { + t.Fatalf("locator rewrite lost Settings: %v %v", reloaded, err) + } +} + +func TestWizardSeedDoesNotOverrideLiveSettings(t *testing.T) { + path, database, st := importedLiveSettings(t) + wizard := &Config{ + Site: Site{ + Name: "Wizard Home", + ControlIntervalS: 5, + GridToleranceW: 42, + WatchdogTimeoutS: 60, + SmoothingAlpha: 0.3, + Gain: 0.5, + SlewRateW: 500, + MinDispatchIntervalS: 5, + }, + Fuse: Fuse{MaxAmps: 25, Phases: 3, Voltage: 230}, + API: API{Port: 8080}, + } + if err := SaveAtomic(path, wizard); err != nil { + t.Fatal(err) + } + loaded, err := Load(path) + if err != nil { + t.Fatal(err) + } + got, err := InitializeStorage(path, database, loaded, st) + if err != nil { + t.Fatal(err) + } + if got.Site.Name != "Live Settings" || got.Fuse.MaxAmps != 16 { + t.Fatalf("wizard seed overwrote Settings: site=%q amps=%v", got.Site.Name, got.Fuse.MaxAmps) + } + reloaded, err := Load(path) + if err != nil || reloaded.Site.Name != "Live Settings" { + t.Fatalf("wizard locator lost Settings: %v %v", reloaded, err) + } +} + func TestRecoveryCannotSubstituteAnotherConfigAtTheSameRevision(t *testing.T) { root := t.TempDir() var first *Config