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
5 changes: 5 additions & 0 deletions .changeset/boot-with-duplicate-site-meters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ftw": patch
---

A config with two `is_site_meter: true` drivers no longer stops the box from starting. The box boots with the first declared driver as the site meter — the same one older versions silently used — ignores the flag on the rest, and logs a clear error naming both drivers so the mistake is visible in the log and the help report. Saving such a config from Settings is still rejected. A driver install that accidentally added a second site meter used to crash-loop the box before the web UI came up, leaving SSH as the only way back in.
5 changes: 5 additions & 0 deletions go/cmd/ftw/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -397,6 +397,11 @@ func main() {
os.Exit(1)
}
slog.Info("config loaded", "site", cfg.Site.Name, "drivers", len(cfg.Drivers))
// Repaired-but-wrong config: ERROR so it reaches the log ring and the
// support report, without stopping a boot the repair made safe.
for _, w := range cfg.LoadWarnings {
slog.Error(w)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Hot reload swallows site-meter warnings

Medium Severity

LoadWarnings are only logged in main after the initial config.Load. Watcher.reload also calls config.Load, which demotes extra is_site_meter flags, then applies the result with no ERROR. A live config.yaml edit that leaves two flags now hot-reloads the first driver silently, so the log ring and help report never see the ambiguity after boot.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d33fb18. Configure here.


// ---- Open persistent state (SQLite) ----
statePath := "state.db"
Expand Down
39 changes: 39 additions & 0 deletions go/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@ type Config struct {
FleetPing *FleetPing `yaml:"fleet_ping,omitempty" json:"fleet_ping,omitempty"`
Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"`
DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"`

// LoadWarnings collects recoverable problems Parse repaired instead of
// refusing the file: an on-disk config an older version accepted must
// still boot, or the operator loses the UI they would fix it with. The
// write path (Settings save, bootstrap) never populates this — it calls
// Validate directly and stays strict. Never serialized.
LoadWarnings []string `yaml:"-" json:"-"`
}

// AppLink controls the outbound connection the FTW app reaches this box
Expand Down Expand Up @@ -1312,13 +1319,45 @@ func Parse(data []byte, baseDir string) (*Config, error) {
c.AppLink = &AppLink{Enabled: false}
}
applyDefaults(&c)
c.demoteExtraSiteMeters()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Report repaired duplicates during hot reload

When a duplicate is introduced while the service is already running, configreload.Watcher.reload calls config.Load and immediately applies the returned config without inspecting LoadWarnings; the only warning loop added by this commit runs once during startup in main. Consequently, a hot edit can be silently repaired—and, if the new duplicate precedes the old meter, can switch the active site-meter driver—without the promised error naming the ambiguity ever reaching the log ring or support report. Log these warnings in the watcher/apply load path as well.

Useful? React with 👍 / 👎.

if err := c.Validate(); err != nil {
return nil, err
}
c.ResolveDriverPaths(baseDir)
return &c, nil
}

// demoteExtraSiteMeters keeps the first declared is_site_meter driver and
// clears the flag on the rest, recording a LoadWarning per demotion.
//
// Load-time only. Duplicate site meters are an operator mistake Validate
// rejects on the write path, but a file already on disk was often written
// by an older version that silently used the first match
// (SiteMeterDriver's order) — refusing it at boot crash-loops the process
// before the HTTP listener binds, and the operator loses the very UI they
// would fix the config with (field incident 2026-08-29: a driver install
// on v1.15.0 left two site meters; the box updated to v2.3.0 and went
// dark until SSH). Demoting reproduces exactly what the older version
// dispatched against, and the warning makes the ambiguity visible where
// silence caused it to be missed.
func (c *Config) demoteExtraSiteMeters() {
kept := ""
for i := range c.Drivers {
d := &c.Drivers[i]
if !d.IsSiteMeter {
continue
}
if kept == "" {
kept = d.Name
continue
}
d.IsSiteMeter = false
c.LoadWarnings = append(c.LoadWarnings, fmt.Sprintf(
"config: drivers %q and %q both set is_site_meter: true; keeping %q as the site meter and ignoring the flag on %q — fix config.yaml so exactly one driver has it",
kept, d.Name, kept, d.Name))
}
}

func topLevelYAMLNull(doc *yaml.Node, key string) bool {
if doc == nil || doc.Kind != yaml.DocumentNode || len(doc.Content) != 1 {
return false
Expand Down
52 changes: 46 additions & 6 deletions go/internal/config/validate_site_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,18 @@ import (
)

func loadSiteValidationConfig(t *testing.T, yaml string) error {
t.Helper()
_, err := loadSiteValidationConfigFull(t, yaml)
return err
}

func loadSiteValidationConfigFull(t *testing.T, yaml string) (*Config, error) {
t.Helper()
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
_, err := Load(path)
return err
return Load(path)
}

func TestLoadRejectsMoreThanThreeFusePhases(t *testing.T) {
Expand Down Expand Up @@ -47,7 +52,14 @@ func meterDriver(name string, siteMeter bool) Driver {
}
}

func TestLoadRejectsDuplicateSiteMeters(t *testing.T) {
// Load repairs duplicate site meters instead of refusing the file: a
// config an older version booted with (first match won silently) must
// keep booting after an update, or the operator loses the UI they would
// fix it with. Field incident 2026-08-29: a driver install on v1.15.0
// left two is_site_meter drivers, and the v2.3.0 update crash-looped the
// box before the HTTP listener bound. The write path stays strict — see
// TestValidateRejectsDuplicateSiteMeters.
func TestLoadDemotesDuplicateSiteMetersAndWarns(t *testing.T) {
yaml := strings.Replace(minimalYAML, "api:\n", `
- name: second-meter
lua: drivers/second-meter.lua
Expand All @@ -57,9 +69,37 @@ func TestLoadRejectsDuplicateSiteMeters(t *testing.T) {
host: 192.168.1.154
api:
`, 1)
err := loadSiteValidationConfig(t, yaml)
if err == nil || err.Error() != "exactly one driver may set is_site_meter: true (found 2)" {
t.Errorf("Load error = %v, want duplicate site meter validation error", err)
cfg, err := loadSiteValidationConfigFull(t, yaml)
if err != nil {
t.Fatalf("Load with duplicate site meters: %v", err)
}
if got := cfg.SiteMeterDriver(); got != "ferroamp" {
t.Errorf("site meter = %q, want the first declared %q", got, "ferroamp")
}
for _, d := range cfg.Drivers {
if d.Name == "second-meter" && d.IsSiteMeter {
t.Error("second-meter kept is_site_meter after load")
}
}
if len(cfg.LoadWarnings) != 1 ||
!strings.Contains(cfg.LoadWarnings[0], `"ferroamp"`) ||
!strings.Contains(cfg.LoadWarnings[0], `"second-meter"`) {
t.Errorf("LoadWarnings = %q, want one warning naming both drivers", cfg.LoadWarnings)
}
}

// The write path (Settings save, bootstrap POST /api/config) calls
// Validate directly and must keep rejecting the ambiguity — the operator
// is present to fix it before it persists.
func TestValidateRejectsDuplicateSiteMeters(t *testing.T) {
c := &Config{
Site: Site{SmoothingAlpha: 0.3},
Fuse: Fuse{MaxAmps: 16, Phases: 3, Voltage: 230},
Drivers: []Driver{meterDriver("a", true), meterDriver("b", true)},
}
if err := c.Validate(); err == nil ||
err.Error() != "exactly one driver may set is_site_meter: true (found 2)" {
t.Errorf("Validate error = %v, want duplicate site meter validation error", err)
}
}

Expand Down