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
+9
View File
@@ -229,6 +229,15 @@ type Manager struct {
// volume leg is unit-testable without Docker. Nil → the real restoreDockerVolumesFrom.
volumeReplayFrom func(stackName, dumpDir string) (int, error)
// R-893 option A seams (offbox_restore_undo.go) — the live-volume copy taken before an off-site restore replaces
// the app's volumes, so a failed restore can put them back. Nil → the real docker calls.
undoVolumeProbeFn func(vol string) (bytes int64, exists bool, err error)
undoVolumeTarFn func(vol, dir string) error
undoVolumeRemoveFn func(vol string) error
// restorePutBackNotify (R-893 option A) is called once when a failed off-site restore was put back and the app
// started again. Operator-tier, the same seam shape as restoreHoldNotify.
restorePutBackNotify func(stack string, replayErr error)
// R-403 unit-REHYDRATE seam — the copy that refills the primary unit from the Tier-2 mirror after
// a restore, overridable so the "only when hollow" condition and the byte-identical non-effect are
// testable without rsync. Nil → the real rsyncMirror. It is a SEPARATE seam from tier2Mirror on
@@ -796,6 +796,27 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
return res, util.MsgError("err.backup.adatbazis_masolat_csonka_nem_indult", stack)
}
// --- R-893 option A (D20 = A): THE LIVE VOLUMES' COPY MUST FIT, BEFORE ANYTHING STOPS -----------
// Every volume the snapshot will replace is copied right after the stop, so a failed restore can put it back
// (offbox_restore_undo.go). Measured here, while nothing is touched: the copy sits on the same namespace as the
// restore's own files, so it is added to the R-357 need and checked by the same exact rule (no multiplier — the
// sizes are measured, not predicted). An unmeasurable volume refuses, as an unmeasurable scratch does.
undoVols := snapshotVolumeNames(filepath.Join(scratchUnit, "volume-dumps"))
var undoExists map[string]bool
if len(undoVols) > 0 {
volNeed, ex, pErr := m.planLiveVolumeCopy(undoVols)
if pErr != nil {
m.logger.Printf("[ERROR] [offbox] %s: REFUSING the destructive restore — the live volumes could not be measured for their copy: %v; the app was NOT stopped", stack, pErr)
return res, util.MsgError("err.backup.restore_undo_unmeasured", stack)
}
if free < need+volNeed {
m.logger.Printf("[WARN] [offbox] %s: REFUSING the destructive restore — need %d B (restore %d B + copy of the live volumes %d B), free %d B on %s; the app was NOT stopped",
stack, need+volNeed, need, volNeed, free, liveNs)
return res, util.MsgError("err.backup.restore_undo_no_space", humanizeBytes(need+volNeed), humanizeBytes(free))
}
undoExists = ex
}
// --- WHICH VERSION COMES BACK (v0.275.0, R-696, `07` §6.6) ---------------------------------
// Until v0.275.0 this path never wrote the definition, so after any update the snapshot's data
// (last night's version) was started by the app's NEW definition — for an engine step that is the
@@ -938,6 +959,22 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if err := m.stackProvider.StopStack(stack); err != nil {
m.logger.Printf("[WARN] [offbox] could not stop %s before reconstitution: %v (continuing)", stack, err)
}
// R-893 option A: the live volumes the snapshot will replace, copied with the app stopped and before anything
// moved. A copy that fails restarts the app as it was — nothing has been touched. Removed when the restore
// ends, unless a failed put-back keeps it for support.
var undo *liveVolumeCopy
if len(undoVols) > 0 {
c, cErr := m.copyLiveVolumes(stack, liveNs, undoVols, undoExists)
if cErr != nil {
m.logger.Printf("[ERROR] [offbox] %s: the copy of the live volumes failed: %v — nothing was restored, starting the app as it was", stack, cErr)
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: restart after a failed volume copy also failed: %v", stack, sErr)
}
return res, util.MsgError("err.backup.restore_undo_copy_failed", stack)
}
undo = c
defer undo.drop(m, stack)
}
// v0.275.0: the snapshot's own definition, written while nothing of the app's data has been touched
// yet — a failure here restarts the app as it was.
if defineFromSnapshot {
@@ -979,9 +1016,10 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if cErr != nil {
// R-893 option C (security review 2026-10-08): once the snapshot's (possibly older) definition
// is written, a start would run that version on the live data — HOLD the app instead.
// R-893 option A: no volume has moved yet, so only the live definition is written back and the app starts.
// The failing copy may have placed part of its files, so the sentence names placed files (+1).
if definitionMoved {
m.holdAppAfterMixedRestore(stack, cErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
return res, m.putBackAfterFailedRestore(stack, cErr, liveDef, undo, false, res.FilesPlaced+1, restartStack, "err.backup.restore_failed_held_mixed")
}
// Nothing moved yet: best-effort bring-up — a failed restore must not also be an outage.
if sErr := restartStack(); sErr != nil {
@@ -1013,9 +1051,10 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// already be the snapshot's — HOLD the app rather than start it on a mix.
// R-893 option C (security review 2026-10-08): a volume already replaced, or the snapshot's
// definition written → HOLD (the volume detail reaches the operator through the hold notice).
if definitionMoved || nVols > 0 {
m.holdAppAfterMixedRestore(stack, vErr, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
// R-893 option A: the replay removes a volume BEFORE it recreates it, so even nVols == 0 may have lost one —
// with a copy, it is always poured back; with no copy the old rule decides (and holds when one moved).
if definitionMoved || nVols > 0 || undo != nil {
return res, m.putBackAfterFailedRestore(stack, vErr, liveDef, undo, nVols > 0 || undo != nil, res.FilesPlaced, restartStack, "err.backup.restore_failed_held_mixed")
}
// Nothing replaced yet: bring the app back up rather than leaving an outage, then surface it.
if sErr := restartStack(); sErr != nil {
@@ -1034,8 +1073,7 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
if err := m.stackProvider.StartStackServices(stack, dbServices); err != nil {
// R-893 option C (security review 2026-10-08): volumes or the definition already moved → HOLD.
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, err, liveDef)
return res, util.MsgError("err.backup.restore_failed_held_mixed", stack)
return res, m.putBackAfterFailedRestore(stack, err, liveDef, undo, res.VolumesReplayed > 0, res.FilesPlaced, restartStack, "err.backup.restore_failed_held_mixed")
}
// Best-effort bring-up: a failed restore must not also be an outage.
if sErr := restartStack(); sErr != nil {
@@ -1065,6 +1103,9 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// the customer type into it and makes the damage permanent. Hold it instead —
// operator ruling, 2026-08-22.
m.logger.Printf("[ERROR] [offbox] %s: ROLLBACK ALSO FAILED (%v) — holding the app stopped; replay error was: %v", stack, rbErr, iErr)
if undo != nil {
undo.keep = true // R-893: the volumes' copy is a second way back for support
}
m.holdAppAfterFailedRollback(stack, iErr, rbErr)
// R-383: the undo copy is DESCRIBED FROM DISK, never from the path alone. See
// undoCopyPhrase — this sentence used to assert the file existed in exactly the
@@ -1080,16 +1121,22 @@ func (m *Manager) ReconstituteFromOffsite(ctx context.Context, stack string, ack
// Pinned by r893_mixed_restore_test.go; the plain case keeps TestR379_ScenarioA.
// Placed FILES alone do not hold (option C as ruled): that mix is what „put back exactly as it
// was" (option A, the next slice) removes.
// R-893 option A (D20 = A): the rows are back, but the snapshot's volumes (and maybe its definition) are
// under them — the copy of the live volumes is poured back over the rolled-back database, the live
// definition written back, and the app starts exactly as it was. A put-back that fails holds, as before.
if definitionMoved || res.VolumesReplayed > 0 {
m.holdAppAfterMixedRestore(stack, iErr, liveDef)
return res, util.MsgError("err.backup.db_restore_failed_held_mixed", stack)
return res, m.putBackAfterFailedRestore(stack, iErr, liveDef, undo, res.VolumesReplayed > 0, res.FilesPlaced, restartStack, "err.backup.db_restore_failed_held_mixed")
}
if sErr := restartStack(); sErr != nil {
m.logger.Printf("[WARN] [offbox] %s: start after a successful rollback failed: %v", stack, sErr)
}
// Says BOTH things. A message that reported only the failure would leave the customer
// believing their data was gone when it is back — the omission of a GAIN is as
// misleading as the omission of a loss.
// misleading as the omission of a loss. R-893: placed files are not taken back, so with
// placed files the sentence says that rather than „your data is back as it was".
if res.FilesPlaced > 0 {
return res, putBackMessage(stack, res.FilesPlaced)
}
return res, util.MsgError("err.backup.db_restore_failed_rolled_back", stack)
}
}
@@ -0,0 +1,262 @@
package backup
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/dockerexec"
"gitea.dooplex.hu/admin/felhom-controller/internal/util"
)
// R-893 option A (operator decision D20 = A, 2026-10-20; `09` §3 decision 192, second half; `07` §6.3):
// a failed off-site restore of one app puts the app back EXACTLY as it was and starts it.
//
// The off-site restore (ReconstituteFromOffsite) replaces the app's named volumes with the snapshot's BEFORE it
// replays the snapshot's database copy. Until now a failed replay put only the database ROWS back (the undo dump)
// on top of the snapshot's volume — measured 2026-10-19 on 9202: the newest rows over the backup's older volume,
// a table only the backup had left behind — and the app was held stopped for support.
//
// Now, for every volume the snapshot will replace:
// - BEFORE THE STOP the box checks that a copy of those volumes fits beside the restore (the same exact-size
// rule the restore's own gate uses, R-357) — not enough → refused, nothing stopped;
// - RIGHT AFTER THE STOP it tars each of them (the nightly volume-dump helper, tmp + fsync + rename) into
// <namespace>/backups/restore-undo/<app>/ — outside every capture set (captureset.go skips backups/);
// - on ANY failure after the first volume moved it stops the app, pours the copies back (the restore's own
// volume helper), removes a volume the snapshot added, writes the live definition back and starts the app;
// - a put-back that fails keeps the copy on disk and HOLDS the app (the R-893 option C hold, unchanged);
// - on success the copy is removed.
//
// Pinned by r893_putback_test.go.
// liveVolumeCopy is the pre-restore copy of the volumes an off-site restore will replace.
type liveVolumeCopy struct {
dir string // where the tars are; removed on success
copied []string // volumes that existed and were copied (each <dir>/<vol>.tar)
absent []string // volumes the snapshot adds that did not exist before (removed on a put-back)
keep bool // set when a put-back could not be done: the copy is the support path back, never removed
}
// restoreUndoDir is where the live-volume copy of one restore lives. Under backups/ on purpose: no capture set
// includes it (appbackup.captureset skips backups/), and backups/primary — the recovery unit — is never written.
func restoreUndoDir(nsRoot, stack string) string {
return filepath.Join(nsRoot, "backups", "restore-undo", stack)
}
// snapshotVolumeNames lists the volumes the snapshot's unit will replace (one <vol>.tar each), sorted.
func snapshotVolumeNames(unitVolumeDir string) []string {
entries, err := os.ReadDir(unitVolumeDir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(e.Name(), ".tar") {
out = append(out, strings.TrimSuffix(e.Name(), ".tar"))
}
}
sort.Strings(out)
return out
}
// undoVolumeProbe measures a live volume: its byte size and whether it exists at all.
func (m *Manager) undoVolumeProbe(vol string) (int64, bool, error) {
if m.undoVolumeProbeFn != nil {
return m.undoVolumeProbeFn(vol)
}
if err := dockerexec.Command("docker", "volume", "inspect", vol).Run(); err != nil {
return 0, false, nil // `docker volume inspect` fails only for a volume that does not exist
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
out, err := dockerexec.CommandContext(ctx, "docker", "run", "--rm", "-v", vol+":/v:ro", "alpine", "du", "-sb", "/v").Output()
if err != nil {
return 0, true, fmt.Errorf("measuring volume %s: %w", vol, err)
}
f := strings.Fields(string(out))
if len(f) == 0 {
return 0, true, fmt.Errorf("measuring volume %s: du printed nothing", vol)
}
n, err := strconv.ParseInt(f[0], 10, 64)
return n, true, err
}
// undoVolumeTar writes <dir>/<vol>.tar with the nightly helper's crash-safe shape (tmp, fsync, rename).
func (m *Manager) undoVolumeTar(vol, dir string) error {
if m.undoVolumeTarFn != nil {
return m.undoVolumeTarFn(vol, dir)
}
tmp := filepath.Join(dir, vol+".tar.tmp")
if out, err := m.tarVolumeOrDefault(vol, dir); err != nil {
os.Remove(tmp)
return fmt.Errorf("copying volume %s: %s: %w", vol, strings.TrimSpace(string(out)), err)
}
if err := atomicPromoteTar(tmp, filepath.Join(dir, vol+".tar")); err != nil {
os.Remove(tmp)
return fmt.Errorf("copying volume %s: %w", vol, err)
}
return nil
}
func (m *Manager) undoVolumeRemove(vol string) error {
if m.undoVolumeRemoveFn != nil {
return m.undoVolumeRemoveFn(vol)
}
if out, err := dockerexec.Command("docker", "volume", "rm", "-f", vol).CombinedOutput(); err != nil {
return fmt.Errorf("removing volume %s: %s: %w", vol, strings.TrimSpace(string(out)), err)
}
return nil
}
// planLiveVolumeCopy measures the volumes the snapshot will replace — BEFORE anything is stopped. It returns
// the bytes the copy needs and which of the volumes exist. A volume that cannot be measured refuses the
// restore (fail closed, the R-357 direction: an unmeasurable copy must not sail into the destructive phase).
func (m *Manager) planLiveVolumeCopy(vols []string) (need int64, exists map[string]bool, err error) {
exists = map[string]bool{}
for _, v := range vols {
n, ok, pErr := m.undoVolumeProbe(v)
if pErr != nil {
return 0, nil, pErr
}
exists[v] = ok
if ok {
need += n
}
}
return need, exists, nil
}
// copyLiveVolumes tars every existing volume of the plan into a fresh restoreUndoDir. Runs with the app
// STOPPED, so each copy is consistent. On any error the partial copy is removed and nothing has moved yet.
func (m *Manager) copyLiveVolumes(stack, nsRoot string, vols []string, exists map[string]bool) (*liveVolumeCopy, error) {
dir := filepath.Join(restoreUndoDir(nsRoot, stack), time.Now().UTC().Format("20060102T150405Z"))
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, err
}
c := &liveVolumeCopy{dir: dir}
start := time.Now()
for _, v := range vols {
if !exists[v] {
c.absent = append(c.absent, v)
continue
}
if err := m.undoVolumeTar(v, dir); err != nil {
os.RemoveAll(dir)
return nil, err
}
c.copied = append(c.copied, v)
}
m.logger.Printf("[INFO] [offbox] %s: the live volumes are copied before the restore (%d copied, %d new from the snapshot) in %s — %s (R-893)",
stack, len(c.copied), len(c.absent), time.Since(start).Round(time.Millisecond), dir)
return c, nil
}
// drop removes the copy (deferred by the restore). A kept copy (a put-back that failed) stays for support.
func (c *liveVolumeCopy) drop(m *Manager, stack string) {
if c == nil || c.dir == "" {
return
}
if c.keep {
m.logger.Printf("[WARN] [offbox] %s: the restore's volume copy is KEPT for support at %s", stack, c.dir)
return
}
if err := os.RemoveAll(c.dir); err != nil {
m.logger.Printf("[WARN] [offbox] %s: could not remove the restore's volume copy %s: %v", stack, c.dir, err)
return
}
_ = os.Remove(filepath.Dir(c.dir)) // the per-app folder, when it is now empty
}
// putBack pours the copy back: every copied volume exactly as it was, every volume the snapshot added removed.
// The app must be stopped. An error means the volumes are NOT known to be as before.
func (m *Manager) putBack(stack string, c *liveVolumeCopy) error {
if c == nil {
return nil
}
volReplay := m.volumeReplayFrom
if volReplay == nil {
volReplay = m.restoreDockerVolumesFrom
}
if len(c.copied) > 0 {
n, err := volReplay(stack, c.dir)
if err != nil {
return err
}
if n != len(c.copied) {
return fmt.Errorf("put back %d of %d volume(s)", n, len(c.copied))
}
}
for _, v := range c.absent {
if err := m.undoVolumeRemove(v); err != nil {
return err
}
}
return nil
}
// putBackAfterFailedRestore is option A's failure branch: stop, put the volumes and the definition back, start.
// When the put-back cannot be done exactly, the app is HELD (holdAppAfterMixedRestore, unchanged; holdKey is the
// failure's own held sentence) and the copy stays on disk. volumesMoved with no copy is such a case: nothing can
// put them back. filesPlaced chooses the household's sentence: placed files are not taken back (as ruled for
// option C, a file mix does not hold), so the sentence then says so instead of „nothing changed".
func (m *Manager) putBackAfterFailedRestore(stack string, cause error, live *liveDefinition, c *liveVolumeCopy, volumesMoved bool,
filesPlaced int, restart func() error, holdKey string) error {
if volumesMoved && c == nil {
m.logger.Printf("[ERROR] [offbox] %s: volumes were replaced and there is no copy to put back — holding the app", stack)
m.holdAppAfterMixedRestore(stack, cause, live)
return util.MsgError(holdKey, stack)
}
if err := m.stackProvider.StopStack(stack); err != nil {
m.logger.Printf("[WARN] [offbox] %s: stopping the app before the put-back failed: %v", stack, err)
}
if volumesMoved {
if err := m.putBack(stack, c); err != nil {
c.keep = true
m.logger.Printf("[ERROR] [offbox] %s: putting the live volumes back FAILED: %v — the copy stays at %s; holding the app", stack, err, c.dir)
m.holdAppAfterMixedRestore(stack, cause, live)
return util.MsgError(holdKey, stack)
}
}
if live != nil {
if err := m.stackProvider.RecreateStackDefinitionFromUnit(stack, live.dir, live.env); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: writing the live definition back FAILED: %v — holding the app", stack, err)
m.holdAppAfterMixedRestore(stack, cause, live)
return util.MsgError(holdKey, stack)
}
}
m.logger.Printf("[WARN] [offbox] %s: the restore failed (%v) — the live volumes and definition are put back; starting the app (R-893)", stack, cause)
if m.restorePutBackNotify != nil {
m.restorePutBackNotify(stack, cause)
}
if err := restart(); err != nil {
m.logger.Printf("[ERROR] [offbox] %s: start after the put-back failed: %v (the copy stays at %s)", stack, err, c.dirOrNone())
return util.MsgError("err.backup.restore_failed_put_back_not_started", stack)
}
return putBackMessage(stack, filesPlaced)
}
func (c *liveVolumeCopy) dirOrNone() string {
if c == nil {
return "(no volume copy)"
}
return c.dir
}
// putBackMessage is the household's sentence after a failed restore whose data is back as it was before.
// Files the restore already copied are not taken back, so with placed files the sentence says that too.
func putBackMessage(stack string, filesPlaced int) error {
if filesPlaced > 0 {
return util.MsgError("err.backup.restore_failed_put_back_files", stack)
}
return util.MsgError("err.backup.restore_failed_put_back", stack)
}
// SetRestorePutBackNotify wires the operator notification for a failed restore that was put back (main.go).
func (m *Manager) SetRestorePutBackNotify(fn func(stack string, replayErr error)) {
m.restorePutBackNotify = fn
}
@@ -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")
@@ -0,0 +1,284 @@
package backup
import (
"context"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// R-893 option A (operator decision D20 = A, 2026-10-20): a failed off-site restore puts the app's live volumes
// back EXACTLY and starts the app. offbox_restore_undo.go. These tests pin the CONSEQUENCE — what is poured back,
// what is removed, what runs, what stays on disk — not only that a function was called.
const r893LiveVol, r893NewVol = "immich_pgdata", "immich_newvol"
// putBackRig records the volume legs: what the snapshot replay saw, and what the put-back poured, BYTE FOR BYTE.
type putBackRig struct {
scratchVolDir string
undoRoot string
snapReplayErr error // the snapshot's own volume replay
putBackErr error // the put-back from the copy
snapReplays int
poured map[string]string // volume → the bytes poured back from the copy
removed []string
tarred []string
tarErr error
probeErr error
}
func r893PutBackFixture(t *testing.T) (*Manager, *r893Provider, *putBackRig, *int, *int, *int) {
t.Helper()
m, vp, rolled, notified, putBack := r893FixtureA(t, false, 0)
unit := vtSnapshotUnit(t, m, "16", true)
rig := &putBackRig{scratchVolDir: filepath.Join(unit, "volume-dumps"), poured: map[string]string{}}
// The snapshot holds two volumes: one the box has today, one it does not (the snapshot adds it).
mustWrite(t, filepath.Join(rig.scratchVolDir, r893LiveVol+".tar"), "SNAPSHOT bytes")
mustWrite(t, filepath.Join(rig.scratchVolDir, r893NewVol+".tar"), "SNAPSHOT new")
_, liveNs, err := m.offboxRestoreScratchDir("immich")
if err != nil {
t.Fatal(err)
}
rig.undoRoot = restoreUndoDir(liveNs, "immich")
m.undoVolumeProbeFn = func(vol string) (int64, bool, error) {
if rig.probeErr != nil {
return 0, false, rig.probeErr
}
if vol == r893LiveVol {
return 1000, true, nil
}
return 0, false, nil
}
m.undoVolumeTarFn = func(vol, dir string) error {
if rig.tarErr != nil {
return rig.tarErr
}
rig.tarred = append(rig.tarred, vol)
return os.WriteFile(filepath.Join(dir, vol+".tar"), []byte("LIVE bytes of "+vol), 0o600)
}
m.undoVolumeRemoveFn = func(vol string) error { rig.removed = append(rig.removed, vol); return nil }
m.volumeReplayFrom = func(_ string, dir string) (int, error) {
entries, _ := os.ReadDir(dir)
n := 0
if dir == rig.scratchVolDir {
rig.snapReplays++
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tar") {
n++
}
}
if rig.snapReplayErr != nil {
return 0, rig.snapReplayErr
}
return n, nil
}
if !strings.HasPrefix(dir, rig.undoRoot) {
t.Errorf("a volume replay from an unexpected folder %q", dir)
return 0, errors.New("unexpected folder")
}
if rig.putBackErr != nil {
return 0, rig.putBackErr
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".tar") {
b, _ := os.ReadFile(filepath.Join(dir, e.Name()))
rig.poured[strings.TrimSuffix(e.Name(), ".tar")] = string(b)
n++
}
}
return n, nil
}
return m, vp, rig, rolled, notified, putBack
}
func r893Stops(vp *r893Provider) int {
n := 0
for _, c := range vp.calls {
if c == "stop" {
n++
}
}
return n
}
func undoCopies(t *testing.T, root string) []string {
t.Helper()
var out []string
_ = filepath.Walk(root, func(p string, fi os.FileInfo, _ error) error {
if fi != nil && !fi.IsDir() {
out = append(out, p)
}
return nil
})
sort.Strings(out)
return out
}
// The whole point of option A, in one run: the replay fails after the snapshot's volumes replaced the live ones;
// the live volume comes back with EXACTLY the bytes copied before the restore, the volume the snapshot added is
// removed, the app starts, nothing is held, the copy is gone, the operator is told once.
// RED-PROOF: make putBackAfterFailedRestore skip m.putBack (volumesMoved ignored) → poured is empty → FAILS.
func TestR893A_AFailedReplayPutsTheLiveVolumesBackExactly(t *testing.T) {
m, vp, rig, rolled, notified, putBack := r893PutBackFixture(t)
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed replay must be surfaced")
}
if rig.snapReplays != 1 {
t.Fatalf("the snapshot's volumes must have been replayed once before the failure, got %d", rig.snapReplays)
}
if got := rig.poured[r893LiveVol]; got != "LIVE bytes of "+r893LiveVol {
t.Fatalf("the live volume was poured back as %q — want the exact pre-restore copy", got)
}
if len(rig.poured) != 1 {
t.Errorf("only the volume that existed before may be poured back, got %v", rig.poured)
}
if len(rig.removed) != 1 || rig.removed[0] != r893NewVol {
t.Errorf("the volume the snapshot added must be removed, got %v", rig.removed)
}
if !vp.fullStarted {
t.Fatalf("the app must run again after the put-back — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); held {
t.Fatal("a put-back app must not be held")
}
if *rolled != 1 || *notified != 0 || *putBack != 1 {
t.Errorf("rollback %d (want 1), hold notices %d (want 0), put-back notices %d (want 1)", *rolled, *notified, *putBack)
}
if left := undoCopies(t, rig.undoRoot); len(left) != 0 {
t.Errorf("the copy must be removed after a put-back that worked, left %v", left)
}
if !strings.Contains(err.Error(), "visszaker") {
t.Errorf("the sentence must say the data is back: %v", err)
}
}
// RED-PROOF: drop `|| undo != nil` from the volume-replay failure branch → the app starts with nothing poured back
// (a volume the replay removed stays removed) → FAILS.
func TestR893A_AFailedVolumeReplayStillPutsBackWhenItCountedNothing(t *testing.T) {
m, vp, rig, _, _, putBack := r893PutBackFixture(t)
rig.snapReplayErr = errors.New("volume create failed")
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed volume replay must be surfaced")
}
if got := rig.poured[r893LiveVol]; got != "LIVE bytes of "+r893LiveVol {
t.Fatalf("the replay removes a volume before it recreates it; the copy must be poured back even at a count of 0, got %q", got)
}
if !vp.fullStarted || *putBack != 1 {
t.Fatalf("started=%v put-back notices=%d — want the app running and one notice", vp.fullStarted, *putBack)
}
}
// The copy is checked BEFORE the stop. One byte short → refused, nothing stopped, nothing copied.
// RED-PROOF: remove volNeed from the comparison (`free < need`) → the restore runs → FAILS.
func TestR893A_TheCopyMustFitBeforeAnythingStops(t *testing.T) {
m, vp, rig, _, _, _ := r893PutBackFixture(t)
m.SetOffboxFreeFn(func(string) int64 { return (1 << 20) + 999 }) // the restore needs 1 MiB + the copy 1000 B
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a copy that does not fit must refuse the restore")
}
if n := r893Stops(vp); n != 0 {
t.Fatalf("the refusal must come before the stop — StopStack ran %d time(s)", n)
}
if len(rig.tarred) != 0 || rig.snapReplays != 0 {
t.Errorf("nothing may be copied or replayed on a refusal: tarred %v, replays %d", rig.tarred, rig.snapReplays)
}
if !strings.Contains(err.Error(), "szabad") {
t.Errorf("the refusal must name the free space: %v", err)
}
// The boundary: exactly enough room → the restore proceeds (and fails later on the fixture's replay).
m2, vp2, _, _, _, _ := r893PutBackFixture(t)
m2.SetOffboxFreeFn(func(string) int64 { return (1 << 20) + 1000 })
_, _ = m2.ReconstituteFromOffsite(context.Background(), "immich", false)
if r893Stops(vp2) == 0 {
t.Error("with exactly enough room the restore must run")
}
}
// RED-PROOF: let planLiveVolumeCopy skip a probe error → the restore runs on an unmeasured copy → FAILS.
func TestR893A_AnUnmeasurableVolumeRefusesBeforeAnythingStops(t *testing.T) {
m, vp, rig, _, _, _ := r893PutBackFixture(t)
rig.probeErr = errors.New("du failed")
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("an unmeasurable volume must refuse")
}
if n := r893Stops(vp); n != 0 {
t.Fatalf("StopStack ran %d time(s) on an unmeasured copy", n)
}
}
// A restore that WORKS removes the copy and never pours it back.
// RED-PROOF: remove `defer undo.drop(m, stack)` → the copy stays → FAILS.
func TestR893A_ASuccessfulRestoreRemovesTheCopy(t *testing.T) {
m, vp, rig, _, _, putBack := r893PutBackFixture(t)
m.importDBDump = func(context.Context, DiscoveredDB, string) error { return nil }
if _, err := m.ReconstituteFromOffsite(context.Background(), "immich", false); err != nil {
t.Fatalf("restore: %v", err)
}
if len(rig.tarred) != 1 {
t.Fatalf("the live volume must have been copied before the restore, tarred %v", rig.tarred)
}
if len(rig.poured) != 0 || *putBack != 0 {
t.Errorf("nothing may be poured back after a restore that worked: poured %v, notices %d", rig.poured, *putBack)
}
if left := undoCopies(t, rig.undoRoot); len(left) != 0 {
t.Errorf("the copy must be removed after a restore that worked, left %v", left)
}
if !vp.fullStarted {
t.Error("the restored app must be started")
}
}
// A put-back that fails cannot promise anything: the app is HELD (option C) and the copy STAYS for support.
// RED-PROOF: remove `c.keep = true` → the deferred drop deletes the only copy → FAILS.
func TestR893A_AFailedPutBackHoldsTheAppAndKeepsTheCopy(t *testing.T) {
m, vp, rig, _, notified, putBack := r893PutBackFixture(t)
rig.putBackErr = errors.New("tar xf failed")
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed put-back must be surfaced")
}
if vp.fullStarted {
t.Fatalf("the app must NOT start on volumes that could not be put back — calls %v", vp.calls)
}
if held, _ := m.RestoreHoldFor("immich"); !held {
t.Fatal("a failed put-back must leave the hold")
}
if *notified != 1 || *putBack != 0 {
t.Errorf("hold notices %d (want 1), put-back notices %d (want 0)", *notified, *putBack)
}
left := undoCopies(t, rig.undoRoot)
if len(left) != 1 || filepath.Base(left[0]) != r893LiveVol+".tar" {
t.Fatalf("the copy must stay on disk for support, found %v", left)
}
if low := strings.ToLower(err.Error()); strings.Contains(low, "visszaker") || !strings.Contains(low, "kapcsolatot") {
t.Errorf("the held sentence must not claim the data is back, and must send to support: %q", err.Error())
}
}
// A copy that fails touches nothing: no replay, no definition write, the app starts as it was.
// RED-PROOF: ignore the copy error (carry on with undo == nil) → the snapshot replay runs → FAILS.
func TestR893A_AFailedCopyRestoresNothingAndStartsTheApp(t *testing.T) {
m, vp, rig, rolled, _, _ := r893PutBackFixture(t)
rig.tarErr = errors.New("disk full")
_, err := m.ReconstituteFromOffsite(context.Background(), "immich", false)
if err == nil {
t.Fatal("a failed copy must be surfaced")
}
if rig.snapReplays != 0 || *rolled != 0 || len(vp.defs) != 0 {
t.Fatalf("nothing may move after a failed copy: replays %d, rollbacks %d, definition writes %v", rig.snapReplays, *rolled, vp.defs)
}
if !vp.fullStarted {
t.Fatal("the app must be started again as it was")
}
if left := undoCopies(t, rig.undoRoot); len(left) != 0 {
t.Errorf("a failed copy must leave nothing behind, found %v", left)
}
}