Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/config-seed-not-clobber.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
81 changes: 75 additions & 6 deletions go/internal/config/storage.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package config

import (
"bytes"
"crypto/sha256"
"encoding/json"
"errors"
Expand Down Expand Up @@ -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"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Probe the actual runtime state directory for missing seeds

On the standard native deployment, deploy/ftw.service runs from /var/lib/ftw while passing /etc/ftw/config.yaml, so the default live database opened by main.go is /var/lib/ftw/state.db, not /etc/ftw/state.db. If that seed is deleted, this hard-coded sibling lookup misses the live Settings, Load returns the missing-file error, and main.go enters the setup wizard instead of recovering; the service cannot resume automatically and may be unable to recreate the root-owned /etc/ftw seed. The recovery lookup must use the deployment's runtime state location rather than assuming it is beside the config file.

Useful? React with 👍 / 👎.

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
Expand Down Expand Up @@ -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)
Expand All @@ -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")
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve rollback saves that resemble wizard defaults

When a site was originally configured through the wizard, its legitimate configuration retains 5/500/5; an older Core that saves settings after rollback removes the locator and header but serializes those same nonzero values along with the operator's changes. This predicate therefore classifies that real rollback save as a wizard document, causing InitializeStorage to reload stale SQLite Settings and overwrite the YAML, silently discarding the changes the rollback-import path is intended to preserve. Wizard detection needs provenance that cannot also occur in an ordinary saved configuration.

Useful? React with 👍 / 👎.

}

func SaveStored(st *state.Store, path string, cfg *Config) error {
Expand Down Expand Up @@ -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)
}

Expand Down
130 changes: 130 additions & 0 deletions go/internal/config/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down