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
7 changes: 7 additions & 0 deletions .changeset/sqlite-config-authority.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"ftw": minor
---

Store settings and credentials together in SQLite, with durable commits before applying changes. Import YAML once and retain it as a database locator and recovery export. Remove background YAML reloads. Reject stale Settings forms and preserve the previous live settings on a failed write. Capture current settings in backups and keep forecast learning state unchanged.

Mark the migration as state schema 2 so upgrades take a full backup. Returning to a YAML-only Core requires a matching backup restore.
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# First boot imports this file into SQLite. After import, use FTW Settings.
# Core records config_database here; later edits to these values do not reload.
# FTW config example
# Copy to config.yaml and edit for your site.

Expand Down
33 changes: 29 additions & 4 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,35 @@ schema. The handlers registered in
[`go/internal/api/api.go`](../go/internal/api/api.go) define the HTTP surface. Driver metadata defines
the device catalog. These sources replace manually duplicated reference docs.

Some startup bindings cannot be hot-reloaded, including state paths, API
listener and selected integration transports. Normal device and control
configuration is reloaded through
[`go/internal/configreload`](../go/internal/configreload).
Core imports YAML into a versioned document in SQLite once. The seed file then
holds `config_database`, a path relative to that file. Settings saves commit
the document and credential rows together with SQLite `synchronous=FULL`
before applying them through [`go/internal/configreload`](../go/internal/configreload).
The file watcher has been removed; editing the seed does not change live settings.
The first import keeps older YAML fields so a failed update can return to its
previous Core image. If that older Core later saves settings, it removes the
unknown database locator. The next upgrade detects that changed source and
imports the newer save. An interrupted import with unchanged source bytes
reuses the committed document.
An unreadable settings database stops startup instead of restoring old seed values.

The first import needs write access to the seed file so Core can record which
database owns it. For a read-only mount, copy the seed into the data directory
and point `-config` there before upgrading. Keep a full backup before migration.
Use Settings for later edits. Moving the state database is an offline operation;
API listener and selected integration changes still need a restart.

State schema 2 marks this settings migration, so an update from older Core
versions takes a full backup first. To return to a Core that reads YAML, stop
Core and restore a full backup with its matching Core version. An image-only
downgrade to state schema 1 is refused; the import seed can be older than the
settings saved in SQLite.


Document revisions only prevent stale Settings forms from overwriting a newer
save. They do not change forecast learning revisions, hardware identity, model
weights or the exact bytes of stored forecast snapshots. Backups export YAML
from the same SQLite snapshot so older Core versions also read current settings.

## Remote access boundary

Expand Down
4 changes: 3 additions & 1 deletion docs/backup-and-restore.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Open **FTW Update Center → Full backups** and choose **Create full backup**.
FTW:

1. makes a transactionally consistent SQLite backup without stopping control;
2. collects the rest of the persistent data directory;
2. exports the current config from that database snapshot and collects the rest of the persistent data directory;
3. records Core, Optimizer and active Driver versions;
4. hashes every file, verifies the finished archive and runs SQLite
`quick_check` before publishing it.
Expand Down Expand Up @@ -80,6 +80,8 @@ ftw-backup revert -data /var/lib/ftw -safety /var/lib/.ftw-pre-restore-... -yes

Stop the native FTW service before `restore` or `revert`. `create` opens the
existing database read-only and does not migrate or repair its schema.
Pass `-config` to `create` when the seed has a name other than
`<data>/config.yaml`. The config seed must be inside the data directory.

## Svenska – kortversion att skicka till en användare

Expand Down
2 changes: 2 additions & 0 deletions go/cmd/ftw-backup/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ func run(args []string) error {
func create(args []string) error {
fs := flag.NewFlagSet("create", flag.ContinueOnError)
statePath := fs.String("state", "state.db", "path to state.db")
configPath := fs.String("config", "", "config seed path (default: <data>/config.yaml)")
dataDir := fs.String("data", "", "persistent data directory (default: state.db directory)")
outputDir := fs.String("output", "", "backup destination (default: <data>/backups)")
coreVersion := fs.String("core-version", Version, "core version recorded in component inventory")
Expand All @@ -71,6 +72,7 @@ func create(args []string) error {
defer st.Close()
info, err := backup.Create(context.Background(), backup.CreateOptions{
State: st, StatePath: absState, DataDir: *dataDir, OutputDir: *outputDir,
ConfigPath: *configPath,
Components: backup.ComponentInventory{Core: backup.ComponentVersion{Version: *coreVersion}},
})
if err != nil {
Expand Down
98 changes: 98 additions & 0 deletions go/cmd/ftw/config_storage_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package main

import (
"os"
"path/filepath"
"testing"

"github.com/srcfl/ftw/go/internal/config"
"github.com/srcfl/ftw/go/internal/state"
)

func TestConfigStorageKeepsForecastLearningIdentity(t *testing.T) {
dir := t.TempDir()
database := filepath.Join(dir, "state.db")
path := filepath.Join(dir, "config.yaml")
script := filepath.Join(dir, "meter.lua")
if err := os.WriteFile(script, []byte("measurement code"), 0600); err != nil {
t.Fatal(err)
}
cfg, err := config.Parse([]byte(`
site:
name: Stored site
fuse:
max_amps: 16
api:
port: 8080
app_link:
enabled: false
weather:
provider: open_meteo
latitude: 59
longitude: 18
timezone: Europe/Stockholm
heating_coefficient_w_per_c: 0
planner:
pv_forecast_safety_k: 0
drivers:
- name: meter
lua: meter.lua
is_site_meter: true
capabilities:
standalone: true
config:
scale: 1
enabled: false
`), dir)
if err != nil {
t.Fatal(err)
}
st, err := state.Open(database)
if err != nil {
t.Fatal(err)
}
defer st.Close()
beforeSite := newForecastSiteConfig(st)
beforeSite.Configure(cfg, nil)
before := beforeSite.Snapshot()
const opaque = "{ \"state\": [1, 2, 3] }"
if err := st.SaveConfig("forecast/energyplan_state_v1", opaque); err != nil {
t.Fatal(err)
}
cfg, err = config.InitializeStorage(path, database, cfg, st)
if err != nil {
t.Fatal(err)
}
loaded, err := config.Load(path)
if err != nil {
t.Fatal(err)
}
afterSite := newForecastSiteConfig(st)
afterSite.Configure(loaded, nil)
after := afterSite.Snapshot()
if before.SiteID != after.SiteID || before.LearningRevision != after.LearningRevision || before.Revision != after.Revision || before.WeatherSinceMS != after.WeatherSinceMS {
t.Fatalf("storage changed forecast identity:\nbefore=%+v\nafter=%+v", before, after)
}
if raw, _ := st.LoadConfig("forecast/energyplan_state_v1"); raw != opaque {
t.Fatal("storage reserialized worker state")
}
if loaded.Planner.PVForecastSafetyK == nil || *loaded.Planner.PVForecastSafetyK != 0 {
t.Fatal("explicit zero became the default")
}
}

func TestMissingDatabaseDoesNotStartSetup(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if !isConfigMissing(path) {
t.Fatal("first boot must offer setup")
}
if err := os.WriteFile(path, []byte("config_database: missing.db\n"), 0600); err != nil {
t.Fatal(err)
}
if _, err := config.Load(path); err == nil {
t.Fatal("missing authority did not fail")
}
if isConfigMissing(path) {
t.Fatal("database loss offered destructive first-run setup")
}
}
Loading