R-893 option A (operator D20): a failed off-site restore puts the app's live volumes and definition back exactly and starts it; fit check before the stop; the held sentence made true (R-1169)
gates / gates (push) Successful in 1m11s

This commit is contained in:
2026-10-20 09:17:00 +02:00
parent ae2bfb2f7a
commit 93fb251ea9
13 changed files with 752 additions and 55 deletions
@@ -20,12 +20,14 @@ import (
// an older definition, the snapshot's files and volumes, the newer database — and the household was told
// „your data is back as it was before the restore". That is true only of the database rows.
//
// Now: the database is still rolled back, the LIVE definition is written back, and the app is HELD
// stopped with a sentence that says it needs support. Pinned here:
// Slice 1 (option C) HELD the app. Since option A (operator D20 = A, 2026-10-20) the box puts the app back
// EXACTLY as it was and starts it: the live definition is written back and the copy of the live volumes is
// poured back (r893_putback_test.go pins the volume half). The HOLD remains for the case nothing can undo:
// volumes replaced with no copy to put back. Pinned here:
// (a) the definition write is called a second time, with the live definition;
// (b) the app is NOT started;
// (c) a hold is left (kind restore_mixed), and the operator is notified;
// (d) neither the error nor the hold sentence claims the data is back as it was (hu and en).
// (b) put back → the app IS started, no hold, the operator told once (put-back event);
// (c) no copy → a hold is left (kind restore_mixed), the app is NOT started, the operator notified;
// (d) the held sentences claim neither that the data is back nor that the app runs (hu and en).
// The no-version-change, no-volume case keeps today's behaviour: TestR379_ScenarioA.
// r893Provider records EVERY definition write (vtReconProvider keeps only the last).
@@ -48,6 +50,12 @@ func (p *r893Provider) RecreateStackDefinitionFromUnit(name, composeDir string,
}
func r893Fixture(t *testing.T, versionChange bool, volumes int) (*Manager, *r893Provider, *int, *int) {
m, vp, rolled, notified, _ := r893FixtureA(t, versionChange, volumes)
return m, vp, rolled, notified
}
// r893FixtureA is r893Fixture plus the put-back notification counter (option A).
func r893FixtureA(t *testing.T, versionChange bool, volumes int) (*Manager, *r893Provider, *int, *int, *int) {
t.Helper()
m, prov, _ := reconFixture(t, "20260926T021500Z", "2026-09-26T02:15:01Z", pgDump(1))
m.importDBDump = func(context.Context, DiscoveredDB, string) error { return errors.New("replay blew up") }
@@ -60,6 +68,13 @@ func r893Fixture(t *testing.T, versionChange bool, volumes int) (*Manager, *r893
t.Errorf("notify got replay=%v rollback=%v — want the replay error and NO rollback error", replayErr, rollbackErr)
}
})
putBack := 0
m.SetRestorePutBackNotify(func(_ string, replayErr error) {
putBack++
if replayErr == nil {
t.Error("the put-back notification must carry the error that failed the restore")
}
})
m.volumeReplayFrom = func(string, string) (int, error) { return volumes, nil }
live := "16"
if versionChange {
@@ -72,7 +87,31 @@ func r893Fixture(t *testing.T, versionChange bool, volumes int) (*Manager, *r893
vp := &r893Provider{vtReconProvider: &vtReconProvider{recordingProvider: prov, livePins: ParseComposeImages(prov.composePath)}}
m.SetStackProvider(vp)
vtSnapshotUnit(t, m, "16", true)
return m, vp, &rolled, &notified
return m, vp, &rolled, &notified, &putBack
}
// assertR893PutBack is option A's outcome: the app runs again at its live definition, nothing is held.
func assertR893PutBack(t *testing.T, m *Manager, vp *r893Provider, err error, notified, putBack int) {
t.Helper()
if err == nil {
t.Fatal("a failed restore must still be surfaced as a failure")
}
if !vp.fullStarted {
t.Fatalf("option A: the app must be started again after the put-back — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); held {
t.Fatal("option A: a put-back app must NOT be held")
}
if notified != 0 || putBack != 1 {
t.Errorf("hold notices %d (want 0), put-back notices %d (want 1)", notified, putBack)
}
if len(vp.defs) != 2 || !strings.Contains(strings.Join(vp.defs[1], " "), "postgres:18-alpine") {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", vp.defs)
}
low := strings.ToLower(err.Error())
if !strings.Contains(low, "sikertelen") || !strings.Contains(low, "visszaker") || strings.Contains(low, "kapcsolatot") {
t.Errorf("the sentence must say the restore failed and the data is back, with no support call: %q", err.Error())
}
}
func assertR893Held(t *testing.T, m *Manager, vp *r893Provider, err error, rolled, notified int) {
@@ -120,20 +159,20 @@ func assertR893Held(t *testing.T, m *Manager, vp *r893Provider, err error, rolle
}
}
func TestR893_AVersionChangeThenAFailedReplayHoldsTheAppAtItsLiveDefinition(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, true, 0)
// RED-PROOF (option A): restore the slice-1 branch (holdAppAfterMixedRestore + return held_mixed) after the
// rollback → the app is not started and a hold is left → FAILS.
func TestR893_AVersionChangeThenAFailedReplayStartsTheAppAtItsLiveDefinition(t *testing.T) {
m, vp, rolled, notified, putBack := r893FixtureA(t, true, 0)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
assertR893Held(t, m, vp, err, *rolled, *notified)
// (a)
if len(vp.defs) != 2 {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", vp.defs)
}
if got := strings.Join(vp.defs[1], " "); !strings.Contains(got, "postgres:18-alpine") {
t.Fatalf("the definition written back is %q — want the live 18", got)
if *rolled != 1 {
t.Fatalf("the database rollback must still run exactly once, ran %d", *rolled)
}
assertR893PutBack(t, m, vp, err, *notified, *putBack)
}
func TestR893_AReplacedVolumeThenAFailedReplayHoldsTheApp(t *testing.T) {
// A volume replaced with NO copy to pour back (no snapshot volume tar was measured) cannot be undone: the hold
// stays (option C). In production the copy exists whenever the snapshot holds a volume tar.
func TestR893_AReplacedVolumeWithNoCopyThenAFailedReplayHoldsTheApp(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, false, 1)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
assertR893Held(t, m, vp, err, *rolled, *notified)
@@ -142,29 +181,27 @@ func TestR893_AReplacedVolumeThenAFailedReplayHoldsTheApp(t *testing.T) {
}
}
// Security review 2026-10-08 (G1): the hold covers EVERY failure after the snapshot's definition is written, not only a
// failed replay. Here the DB-only start fails after a version change: before the fix the app was started at the
// snapshot's (older) definition on the live database. Now the live definition is written back and the app is held.
// RED-PROOF: restore `restartStack()` as the only action in the StartStackServices failure branch → started → FAILS.
func TestR893_AVersionChangeThenAFailedDBStartHoldsTheApp(t *testing.T) {
m, vp, rolled, notified := r893Fixture(t, true, 0)
// Security review 2026-10-08 (G1): EVERY failure after the snapshot's definition is written is undone, not only a
// failed replay. Here the DB-only start fails after a version change: before G1 the app was started at the
// snapshot's (older) definition on the live database. Since option A the live definition is written back FIRST and
// the app then starts at it (calls: … recreate, start — the live definition before the start).
// RED-PROOF: restore `restartStack()` as the only action in the StartStackServices failure branch → only one
// definition write (the snapshot's) → FAILS.
func TestR893_AVersionChangeThenAFailedDBStartStartsTheAppAtItsLiveDefinition(t *testing.T) {
m, vp, rolled, notified, putBack := r893FixtureA(t, true, 0)
vp.startSvcErr = context.DeadlineExceeded
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if *rolled != 0 {
t.Errorf("rollback ran %d (want 0: nothing was replayed)", *rolled)
}
if err == nil {
t.Fatal("a failed DB-only start must be surfaced")
}
if vp.fullStarted {
t.Fatalf("the app was STARTED at the snapshot's definition on the live data — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Fatal("no hold was left after a failed DB-only start that followed a version change")
}
if len(vp.defs) != 2 || !strings.Contains(strings.Join(vp.defs[1], " "), "postgres:18-alpine") {
t.Fatalf("definition writes %v — want the snapshot's, then the LIVE one written back", vp.defs)
}
if *rolled != 0 || *notified != 1 {
t.Errorf("rollback ran %d (want 0: nothing was replayed), notified %d (want 1)", *rolled, *notified)
n := len(vp.calls)
if n < 2 || vp.calls[n-2] != "recreate" || vp.calls[n-1] != "start" {
t.Fatalf("the live definition must be written back BEFORE the start — calls %v", vp.calls)
}
assertR893PutBack(t, m, vp, err, *notified, *putBack)
}
// Security review 2026-10-08 (G3): the hold is persisted BEFORE the app is stopped, so a controller that dies in
@@ -172,7 +209,7 @@ func TestR893_AVersionChangeThenAFailedDBStartHoldsTheApp(t *testing.T) {
// the hold is already on disk at the moment it is called.
// RED-PROOF: move SetRestoreHold after StopStack in holdAppAfterMixedRestore → FAILS.
func TestR893_HoldIsPersistedBeforeTheStop(t *testing.T) {
m, vp, _, _ := r893Fixture(t, true, 0)
m, vp, _, _ := r893Fixture(t, true, 1) // a replaced volume with no copy: the case that still holds
seenAtStop := []bool{}
vp.onStop = func() {
held, _ := m.RestoreHoldFor("immich")