v0.237.0: the Update button takes a backup first, and tells the truth (update arc slice 4 — R-448, R-443, R-439)
gates / gates (push) Successful in 13s

POST /api/stacks/{name}/update is now a guarded job answering 202:
cheap refusals (hold — R-439, busy, migration, deploying, memory via the
deploy's own memoryVerdict, a fixed 2 GB disk floor, and no restorable
Tier-2 copy) → backup-first when the proven copy is older than
update.backup_max_age (24h) → safety dump BEFORE the pin moves → pin →
pull (failure puts the pin back) → up → health (.felhom.yml check or 60 s
settle, update.health_timeout 5m). Not healthy → the app is stopped and
HELD (RestoreHold reason update_failed, same store and gate as R-379) and
the page names the backup to restore from; the pin stays. Success is only
ever update_phase=done after health (R-443). UpdateStack is deleted.

The restorable-unit predicate is EXTRACTED to backup.Tier2UnitRestorePoint
and shared with the backups page (row pinned unchanged). The copy is aged
by the last successful Tier-2 copy, not the manifest created_at — measured
on demo-hp that created_at moves only on definition changes.

Crash safety: update-journal.json before each phase; RecoverUpdates before
the boot sweep, ResumeInterruptedUpdates after the guards are wired.

Three unattended start paths ignored a hold and now honour it: the
drive-return gate (restart + boot recreate) and the nightly volume dump.
The nightly capture and Tier-2 run skip held apps so the restore point
survives. No automatic rollback — measured per-app; route back = restore.

Tests A–H across stacks/backup/api/web/cmd; six red-proofs seen to fail.

Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-09-13 11:41:31 +02:00
parent 1552716722
commit 0d402f711d
25 changed files with 2709 additions and 123 deletions
@@ -0,0 +1,168 @@
package backup
import (
"fmt"
"io"
"log"
"path/filepath"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// Update arc slice 4 — the backup side of the guarded update.
// The measured case (demo-hp 2026-09-13, bookstack): the mirror's manifest said 2026-09-12T02:15:29Z
// while its database dump was written 2026-09-13T00:30Z and the Tier-2 run succeeded at 01:30Z. The
// update's age must be the proven COPY time; the manifest date would call a fresh copy stale forever.
func TestSlice4_ProvenCopyTime_IsTheLastSuccessNotTheManifestDate(t *testing.T) {
rp := restorePointFromCoverage(Tier2Coverage{
UnitRestorable: true, UnitPackageDate: "2026-09-12T02:15:29Z",
CopyLastRun: "2026-09-13T01:30:00Z", CopyLastSuccess: "2026-09-13T01:30:00Z",
})
at, ok := rp.ProvenCopyTime()
if !ok || !at.Equal(time.Date(2026, 9, 13, 1, 30, 0, 0, time.UTC)) {
t.Fatalf("proven copy time = %v ok=%v, want the last successful copy", at, ok)
}
// The page still names the PACKAGE date (R-403) — the extraction changes nothing it shows.
if rp.CopyDate != "2026-09-12T02:15:29Z" || !rp.CopyDateProven || rp.PackagePreserved {
t.Errorf("restore point = %+v", rp)
}
}
func TestSlice4_ProvenCopyTime_PreservedPackageUsesThePackageDate(t *testing.T) {
rp := restorePointFromCoverage(Tier2Coverage{
UnitRestorable: true, UnitPackageDate: "2026-09-01T02:00:00Z", UnitLegPreserved: true,
CopyLastSuccess: "2026-09-13T01:30:00Z",
})
if at, ok := rp.ProvenCopyTime(); !ok || !at.Equal(time.Date(2026, 9, 1, 2, 0, 0, 0, time.UTC)) {
t.Errorf("a PRESERVED package is as old as the package, got %v ok=%v", at, ok)
}
}
func TestSlice4_ProvenCopyTime_NoProvenOrNoUnitIsNoRestorePoint(t *testing.T) {
for _, cov := range []Tier2Coverage{
{UnitRestorable: true, CopyLastRun: "2026-09-13T01:30:00Z"}, // attempt, never a success (R-101)
{UnitRestorable: false, CopyLastSuccess: "2026-09-13T01:30:00Z"}, // a copy with no openable unit
{UnitRestorable: true, CopyLastSuccess: "not-a-date"}, // unparseable is unknown, never "now"
} {
if _, ok := restorePointFromCoverage(cov).ProvenCopyTime(); ok {
t.Errorf("%+v must not yield a proven copy time", cov)
}
}
}
func slice4Settings(t *testing.T) *settings.Settings {
t.Helper()
s, err := settings.Load(filepath.Join(t.TempDir(), "settings.json"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return s
}
func TestSlice4_UpdateHoldTextNamesTheTimeAndTheCopy(t *testing.T) {
sett := slice4Settings(t)
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
at := time.Date(2026, 9, 13, 8, 0, 0, 0, time.UTC)
copyAt := time.Date(2026, 9, 13, 1, 30, 0, 0, time.UTC)
if err := m.HoldAfterFailedUpdate("bookstack", at, copyAt); err != nil {
t.Fatal(err)
}
h, ok := sett.GetRestoreHold("bookstack")
if !ok || h.Reason != settings.HoldReasonUpdateFailed || h.CopyDate != "2026-09-13T01:30:00Z" {
t.Fatalf("hold = %+v ok=%v", h, ok)
}
held, why := m.RestoreHoldFor("bookstack")
// Budapest is UTC+2 in September: 08:00Z → 10:00, 01:30Z → 03:30.
want := fmt.Sprintf(UpdateHoldFmt, "bookstack", "2026-09-13 10:00", "2026-09-13 03:30")
if !held || why != want {
t.Errorf("hold text =\n%q\nwant\n%q", why, want)
}
}
func TestSlice4_RestoreHoldTextIsUnchanged(t *testing.T) {
sett := slice4Settings(t)
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
if err := sett.SetRestoreHold(settings.RestoreHold{Stack: "docmost", At: "2026-08-22T14:00:00Z"}); err != nil {
t.Fatal(err)
}
_, why := m.RestoreHoldFor("docmost")
if !strings.Contains(why, "visszaállítása") || !strings.Contains(why, "Vedd fel velünk a kapcsolatot") || strings.Contains(why, "frissítése") {
t.Errorf("an R-379 restore hold must keep its own sentence, got %q", why)
}
}
func TestSlice4_ASuccessfulRestoreClearsOnlyAnUpdateHold(t *testing.T) {
sett := slice4Settings(t)
m := &Manager{logger: log.New(io.Discard, "", 0), settings: sett}
_ = m.HoldAfterFailedUpdate("upd", time.Now(), time.Now())
_ = sett.SetRestoreHold(settings.RestoreHold{Stack: "rst", At: "2026-08-22T14:00:00Z"})
m.clearUpdateHoldAfterRestore("upd")
m.clearUpdateHoldAfterRestore("rst")
if _, ok := sett.GetRestoreHold("upd"); ok {
t.Error("a restore is the route back from a failed update — its hold must be lifted")
}
if _, ok := sett.GetRestoreHold("rst"); !ok {
t.Error("an R-379 restore hold stays operator-cleared")
}
}
func TestSlice4_UpdateBusy(t *testing.T) {
m := &Manager{logger: log.New(io.Discard, "", 0)}
if busy, _ := m.UpdateBusy("app"); busy {
t.Fatal("an idle manager is not busy")
}
if err := m.acquireRunning(); err != nil {
t.Fatal(err)
}
if busy, _ := m.UpdateBusy("app"); !busy {
t.Error("a running backup/restore must make an update wait")
}
m.releaseRunning()
m.BeginRestoreOp("tier2-unit-restore", "other")
if busy, _ := m.UpdateBusy("app"); !busy {
t.Error("a restore op in flight must make an update wait")
}
}
// "A hold that only one path honours is not a hold." The nightly legs are unattended start paths
// (DumpAppVolumesSafe ends in StartStack) and writers of the restore point the hold text names.
//
// COMPANION RED-PROOF (REPORT.md): delete the isHeld skip from runVolumeDumps — the held app is then
// stopped (and restarted) by the nightly backup, and this test fails.
func TestSlice4_NightlyLegsLeaveAHeldAppAlone(t *testing.T) {
h := newAdmissionHarness(t, "held", "free")
h.m.settings = slice4Settings(t)
if err := h.m.HoldAfterFailedUpdate("held", time.Now(), time.Now()); err != nil {
t.Fatal(err)
}
h.m.runVolumeDumps()
for _, n := range append(append([]string{}, h.volDumped...), h.prov.stopped...) {
if n == "held" {
t.Fatalf("the nightly volume dump touched a HELD app (dumped=%v stopped=%v)", h.volDumped, h.prov.stopped)
}
}
if len(h.volDumped) != 1 || h.volDumped[0] != "free" {
t.Errorf("positive control: the unheld app must still be dumped, got %v", h.volDumped)
}
h.m.captureAllRecoveryUnits()
for _, n := range h.prov.infoHits {
if n == "held" {
t.Error("the capture must not rewrite a HELD app's restore point")
}
}
var mirrored []string
h.m.perAppTier2 = func(name string) error { mirrored = append(mirrored, name); return nil }
h.m.RunAllTier2()
for _, n := range mirrored {
if n == "held" {
t.Error("Tier 2 must not mirror over a HELD app's copy")
}
}
if len(mirrored) != 1 {
t.Errorf("positive control: the unheld app must still be mirrored, got %v", mirrored)
}
}