Files
felhom-controller/controller/internal/backup/slice4_update_guard_test.go
T
admin 3e813307cc
gates / gates (push) Successful in 13s
controller v0.241.0: a bind-data app leans on off-site before its own unit; the hold names what the copy holds (R-479)
Operator ruling 2026-09-13. An app with classified binds walks second
drive -> off-site -> own unit (its unit holds no files); volume apps keep
2 -> 1 -> 3. RestoreHold.CopyHolds records what the chosen copy holds and
the sentence ends with it; older holds keep their tier-only sentence.
Tests on both halves; red-proof: a layout-blind order fails the bind case.
2026-09-13 21:47:33 +02:00

196 lines
7.8 KiB
Go

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, UpdateTierSecondDrive); 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(UpdateHoldTierFmt, "bookstack", "2026-09-13 10:00", "második meghajtó", "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(), UpdateTierLocal)
_ = 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(), UpdateTierSecondDrive); 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)
}
}
// v0.238.1 — the gap Scenario F found live: during the update's health wait the app is not yet held,
// and the periodic capture wrote the never-started new definition into its primary unit. An app a
// guarded update is moving must be left alone by all three nightly legs, exactly like a held one.
//
// COMPANION RED-PROOF (REPORT.md): delete the updatingCheck clause from isHeld — the updating app is
// then dumped, captured and mirrored, and this test fails.
func TestSlice4_NightlyLegsLeaveAnAppMidUpdateAlone(t *testing.T) {
h := newAdmissionHarness(t, "updating", "free")
h.m.settings = slice4Settings(t)
h.m.SetUpdatingCheck(func(name string) bool { return name == "updating" })
h.m.runVolumeDumps()
h.m.captureAllRecoveryUnits()
var mirrored []string
h.m.perAppTier2 = func(name string) error { mirrored = append(mirrored, name); return nil }
h.m.RunAllTier2()
for _, list := range [][]string{h.volDumped, h.prov.stopped, h.prov.infoHits, mirrored} {
for _, n := range list {
if n == "updating" {
t.Fatalf("a nightly leg touched an app MID-UPDATE (dumped=%v stopped=%v captured=%v mirrored=%v)", h.volDumped, h.prov.stopped, h.prov.infoHits, mirrored)
}
}
}
if len(mirrored) != 1 || mirrored[0] != "free" {
t.Errorf("positive control: the app not being updated must still be mirrored, got %v", mirrored)
}
}