controller v0.296.0: a backup run cut off by a power cut or restart is said on the backup pages (R-519); the whole-system backup text states today's measurement (R-518)
gates / gates (push) Successful in 30s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-05 12:14:54 +02:00
parent 477e2548db
commit ff69074514
21 changed files with 2700 additions and 21 deletions
+2
View File
@@ -553,6 +553,8 @@ func main() {
backupMgr = backup.NewManager(cfg, sett, logger)
// R-550: load the persisted restore record BEFORE any page can ask for it.
interruptedRestore = loadRestoreRecordAtStartup(backupMgr, cfg.Paths.DataDir, logger)
// R-519: an app-data run the stop cut off is said on the backup pages until the next complete run.
loadRunRecordAtStartup(backupMgr, cfg.Paths.DataDir, logger)
// R-166: use the guard that already ran Recover at startup, not a second one over the same
// file (see SetAppStopGuard — one file, one owner).
backupMgr.SetAppStopGuard(appStopGuard)
@@ -0,0 +1,33 @@
package main
import (
"log"
"path/filepath"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-519 (v0.296.0) — an app-data backup run cut off by a power cut or a restart is said on the backup pages until
// the next complete run (internal/backup/run_record.go). main() calls this right after the restore record, before
// any page or any backup run can read the manager. Pinned by TestMainWiresRunRecord.
// runRecordFileName lives in DataDir, beside settings.json and the restore record.
const runRecordFileName = "appdata-run.json"
func loadRunRecordAtStartup(mgr *backup.Manager, dataDir string, logger *log.Logger) time.Time {
if mgr == nil {
return time.Time{}
}
path := filepath.Join(dataDir, runRecordFileName)
mgr.SetRunRecordPath(path)
cut := mgr.LoadRunRecord()
if logger != nil {
if !cut.IsZero() {
logger.Printf("[WARN] [backup] the app-data backup run started %s was cut off by the stop — the backup pages say so until the next complete run (R-519)", cut.UTC().Format(time.RFC3339))
} else {
logger.Printf("[INFO] [backup] app-data run record wired: %s (an earlier cut still shown: %t)", path, !mgr.InterruptedRunAt().IsZero())
}
}
return cut
}
@@ -0,0 +1,59 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"os"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-519: the function main() calls finds a cut run.
func TestRunRecordAtStartup_FindsACutRun(t *testing.T) {
dir := t.TempDir()
lg := log.New(io.Discard, "", 0)
before := new(backup.Manager)
before.SetRunRecordPath(dir + "/" + runRecordFileName)
// the record a run leaves when the power goes mid-run (markRunStarted is unexported — write its file shape)
writeRunning(t, dir+"/"+runRecordFileName)
after := new(backup.Manager)
if cut := loadRunRecordAtStartup(after, dir, lg); cut.IsZero() || after.InterruptedRunAt().IsZero() {
t.Fatal("a cut run was not found at startup")
}
}
// The seam must be CALLED from main() — an AST call, not a string (a comment would match a string).
// RED-PROOF: delete the call in main() → this fails.
func TestMainWiresRunRecord(t *testing.T) {
f, err := parser.ParseFile(token.NewFileSet(), "main.go", nil, 0)
if err != nil {
t.Fatal(err)
}
found := false
ast.Inspect(f, func(n ast.Node) bool {
if c, ok := n.(*ast.CallExpr); ok {
if id, ok := c.Fun.(*ast.Ident); ok && id.Name == "loadRunRecordAtStartup" {
found = true
}
}
return true
})
if !found {
t.Fatal("main() never calls loadRunRecordAtStartup — a cut run is never said on the page")
}
}
func writeRunning(t *testing.T, path string) {
t.Helper()
b := []byte(`{"running":true,"started_at":"` + time.Date(2026, 9, 14, 19, 40, 3, 0, time.UTC).Format(time.RFC3339) + `"}`)
if err := writeFileForTest(path, b); err != nil {
t.Fatal(err)
}
}
func writeFileForTest(path string, b []byte) error { return os.WriteFile(path, b, 0o600) }