062357f778
Closes the two findings from DIAG-immich-restore-2026-07-19. Viktor deleted 11
immich photos to test offsite restore; both runs flashed success and the photos
stayed gone. Two independent defects.
R-43 — no offsite path could restore a database. All three buttons were
file-only: the two "visszaállítás" actions staged to a scratch folder and never
touched postgres, and place-to-live merged only MISSING files. For a DB-indexed
app the bytes returned and the app still could not see them. The dump was
carried INTO every snapshot and could never be replayed OUT of one.
New ReconstituteFromOffsite (/backup/offbox/reconstitute): safety dump → stop →
files overwritten to the snapshot version → start → the snapshot's own dump
replayed → health wait. Two invariants:
- nothing is ever deleted (-a, no --ignore-existing, no --delete): a file
created after the snapshot survives as an extra;
- the undo exists before the act — the pre-restore- dump is verified ON DISK
before anything is stopped, overwritten or replayed; if it cannot be taken
the operation refuses with zero changes.
The replay reads the SCRATCH unit: the live unit is never overwritten, so
replaying from it would replay the current DB over itself and restore nothing.
R-44 — a manual push shipped an unrefreshed dump (up to ~24h old). That day's
predated the customer's account by four hours and probed to asset:0/user:0/
album:0 inside 52MB whose bulk was immich's shipped geodata. Every run, manual
AND nightly, now refreshes dumps + units BEFORE capturing. Order is the
mechanism: the gap can only ADD files the DB does not reference yet, never
remove one it does. Manifests carry offsite_run_id + dumps_at, so coherence is
verifiable at restore time rather than assumed; the periodic refresh carries a
prior stamp forward and never invents one.
Honesty surfaces, all warn-level and none a gate: unstamped (pre-v0.148) pairs
report their skew, ValidateDump gained an EXACT-match accounts-table sniff for
customer-empty dumps, the completion flash states an outcome instead of a
mechanism, and the missing-only button now says what it does NOT do.
11 tests; 5 red-proofs run and reverted. Two of those found real test weaknesses
rather than confirming strength — the first undo mutation was caught by a second
guard, and the first table-matching test did not discriminate between the two
matchers at all. Both tests were rewritten to the cases that separate them.
NOT in scope: R-41's catalog invariant check, nightly cadence, retention, quota
math, tier-2, and v0.147.x progress semantics beyond one added phase line.
Live acceptance (§9) has NOT run: no capability-map flip, customer-restore row
stays MISSING, R-3 stays DRAFT.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9Nn14TWGzKoqAJAiVwC2s
425 lines
17 KiB
Go
425 lines
17 KiB
Go
package backup
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// R-43/R-44 (v0.148.0) — the coherent-pair + true-restore tests.
|
|
//
|
|
// These exist because the product shipped a restore button for months that could not restore.
|
|
// DIAG-immich-restore-2026-07-19: 11 photos, files intact, timeline empty, two "successful"
|
|
// restores that merged 0 files and never touched postgres. Every test below asserts a behaviour
|
|
// whose absence produced that outcome, so each one is a regression guard for a real incident
|
|
// rather than a description of the current implementation.
|
|
|
|
// recordingProvider records stop/start call ORDER so the reconstitution sequence can be asserted.
|
|
type recordingProvider struct {
|
|
offbox3aProvider
|
|
calls []string
|
|
}
|
|
|
|
func (p *recordingProvider) StopStack(string) error { p.calls = append(p.calls, "stop"); return nil }
|
|
func (p *recordingProvider) StartStack(string) error { p.calls = append(p.calls, "start"); return nil }
|
|
|
|
// The app really is up again after StartStack, so the post-restore health wait returns at once.
|
|
// Leaving it false would make each test sit through the full 90s deadline.
|
|
func (p *recordingProvider) RefreshAndIsRunning(string) bool { return true }
|
|
|
|
// recoveryProvider adds the recovery info CaptureRecoveryUnit needs (the shared 3a provider has none).
|
|
type recoveryProvider struct {
|
|
offbox3aProvider
|
|
stackDir string
|
|
}
|
|
|
|
func (p *recoveryProvider) GetStackRecoveryInfo(name string) (RecoveryInfo, bool) {
|
|
return RecoveryInfo{DisplayName: "Immich", StackDir: p.stackDir}, name == "immich"
|
|
}
|
|
|
|
// pgDump builds a structurally valid postgres dump big enough to clear ValidateDump's 100-byte
|
|
// floor, with the accounts-table COPY block carrying `rows` rows. The R-44 sniff runs only on a
|
|
// dump that already passes structural validation, so a toy fixture would silently skip it.
|
|
func pgDump(rows int) string {
|
|
const head = `-- PostgreSQL database dump
|
|
-- Dumped from database version 16.10
|
|
SET statement_timeout = 0;
|
|
SET lock_timeout = 0;
|
|
SET client_encoding = 'UTF8';
|
|
CREATE TABLE public.asset (id uuid NOT NULL);
|
|
CREATE TABLE public."user" (id uuid NOT NULL, email text);
|
|
COPY public."user" (id, email) FROM stdin;
|
|
`
|
|
var b strings.Builder
|
|
b.WriteString(head)
|
|
for i := 0; i < rows; i++ {
|
|
b.WriteString("id-x\tuser@example.invalid\n")
|
|
}
|
|
b.WriteString("\\.\n") // the COPY-block terminator
|
|
return b.String()
|
|
}
|
|
|
|
// reconFixture builds a manager with a COMPLETED full scratch for `immich`, a snapshot whose unit
|
|
// carries the given coherence stamp, and injectable copy/dump/import seams.
|
|
func reconFixture(t *testing.T, runID, dumpsAt string, dumpBody string) (*Manager, *recordingProvider, *[]string) {
|
|
t.Helper()
|
|
drive := t.TempDir()
|
|
m, sett := newOffboxManager(t)
|
|
prov := &recordingProvider{offbox3aProvider: offbox3aProvider{
|
|
hdd: map[string]string{"immich": drive}, binds: map[string][]ClassifiedBind{}, has: map[string]bool{},
|
|
}}
|
|
m.SetStackProvider(prov)
|
|
if err := sett.AddStoragePath(settings.StoragePath{Path: drive, Label: "USB", Schedulable: true}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
scratch, liveNs, err := m.offboxRestoreScratchDir("immich")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
oldNs := "/felhomdata/ns"
|
|
unitP := oldNs + "/backups/primary/immich"
|
|
dataP := oldNs + "/appdata/immich"
|
|
placements, err := mapOffsiteRestorePaths([]string{unitP, dataP}, "immich", scratch, liveNs)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, pl := range placements {
|
|
if err := os.MkdirAll(pl.src, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if pl.isUnit {
|
|
dd := filepath.Join(pl.src, "db-dumps")
|
|
if err := os.MkdirAll(dd, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if dumpBody != "" {
|
|
if err := os.WriteFile(filepath.Join(dd, "immich-postgres.sql"), []byte(dumpBody), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
man := &RecoveryManifest{SchemaVersion: 1, AppName: "immich", OffsiteRunID: runID, DumpsAt: dumpsAt}
|
|
if err := writeManifest(filepath.Join(pl.src, "manifest.json"), man); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
m.SetOffboxFreeFn(func(string) int64 { return 100 << 30 })
|
|
m.SetOffboxSizer(func(string) int64 { return 1 << 20 })
|
|
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
if contains(args, "snapshots") {
|
|
return []byte(`[{"short_id":"snap1","time":"2026-07-19T06:00:00Z","paths":["` + unitP + `","` + dataP + `"]}]`), nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
|
|
// Seams: one DB, a safety dump that really writes a file, and a recording importer.
|
|
db := DiscoveredDB{StackName: "immich", ContainerName: "immich-postgres", DBType: DBTypePostgres}
|
|
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { return []DiscoveredDB{db}, nil }
|
|
m.SetSafetyDumpFn(func(_ context.Context, d DiscoveredDB, dir string) DumpResult {
|
|
p := filepath.Join(dir, "immich-postgres.sql")
|
|
_ = os.MkdirAll(dir, 0o755)
|
|
_ = os.WriteFile(p, []byte(pgDump(1)), 0o644)
|
|
return DumpResult{DB: d, FilePath: p, Size: 42}
|
|
})
|
|
var imported []string
|
|
m.importDBDump = func(_ context.Context, _ DiscoveredDB, p string) error {
|
|
imported = append(imported, p)
|
|
return nil
|
|
}
|
|
m.SetOffboxFullPlaceCopier(func(_, _ string) (int, error) { return 3, nil })
|
|
return m, prov, &imported
|
|
}
|
|
|
|
// TestReconstituteReplaysDBAndOrdersOperations is Scenario C: the whole point of R-43. A restore of
|
|
// a DB-indexed app must stop the app, place files, restart it and REPLAY the snapshot's dump — and
|
|
// the safety dump must exist before any of it. Before v0.148.0 the replay simply did not happen,
|
|
// which is why the photos never came back.
|
|
func TestReconstituteReplaysDBAndOrdersOperations(t *testing.T) {
|
|
m, prov, imported := reconFixture(t, "20260719T060000Z", "2026-07-19T06:00:00Z", pgDump(1))
|
|
|
|
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
|
if err != nil {
|
|
t.Fatalf("reconstitute: %v", err)
|
|
}
|
|
if res.DBsReplayed != 1 {
|
|
t.Fatalf("expected the snapshot dump to be replayed exactly once, got %d — this is the R-43 defect", res.DBsReplayed)
|
|
}
|
|
if len(*imported) != 1 || !strings.Contains((*imported)[0], "immich-postgres.sql") {
|
|
t.Fatalf("expected an import of the snapshot dump, got %v", *imported)
|
|
}
|
|
// The dump replayed must come from the SCRATCH unit, never the live one: the live unit is
|
|
// deliberately not overwritten, so replaying from it would replay the CURRENT database back over
|
|
// itself and restore nothing.
|
|
if !strings.Contains((*imported)[0], "offsite-restore") {
|
|
t.Fatalf("replay source must be the restored scratch unit, got %s", (*imported)[0])
|
|
}
|
|
if res.FilesPlaced != 3 {
|
|
t.Fatalf("expected the userdata placement to be counted, got %d", res.FilesPlaced)
|
|
}
|
|
// stop BEFORE the file copy, start BEFORE the replay (ImportDump needs a live container).
|
|
if got := strings.Join(prov.calls, ","); got != "stop,start" {
|
|
t.Fatalf("expected stop then start around the restore, got %q", got)
|
|
}
|
|
if res.SafetyDump == "" {
|
|
t.Fatal("no safety dump recorded — the undo must exist")
|
|
}
|
|
if _, err := os.Stat(res.SafetyDump); err != nil {
|
|
t.Fatalf("safety dump not on disk: %v", err)
|
|
}
|
|
if !strings.HasPrefix(filepath.Base(res.SafetyDump), preRestoreDumpPrefix) {
|
|
t.Fatalf("safety dump must carry the pre-restore prefix so it is never replayed as a source, got %s", filepath.Base(res.SafetyDump))
|
|
}
|
|
}
|
|
|
|
// TestReconstituteRefusesWhenSafetyDumpFails is the RED-PROOF for the undo invariant: a replay whose
|
|
// previous state was not captured is an overwrite with no way back, so it must not happen at all —
|
|
// and it must abort with the live app untouched (no stop, no copy).
|
|
func TestReconstituteRefusesWhenSafetyDumpFails(t *testing.T) {
|
|
m, prov, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", pgDump(1))
|
|
m.SetSafetyDumpFn(func(_ context.Context, d DiscoveredDB, _ string) DumpResult {
|
|
return DumpResult{DB: d, Error: context.DeadlineExceeded}
|
|
})
|
|
var copied bool
|
|
m.SetOffboxFullPlaceCopier(func(_, _ string) (int, error) { copied = true; return 1, nil })
|
|
|
|
_, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
|
if err == nil {
|
|
t.Fatal("expected a refusal when the safety dump cannot be taken")
|
|
}
|
|
if len(*imported) != 0 {
|
|
t.Fatalf("REPLAYED WITHOUT AN UNDO — the exact thing the invariant forbids: %v", *imported)
|
|
}
|
|
if copied {
|
|
t.Fatal("files were overwritten despite the refusal — the abort must leave live data untouched")
|
|
}
|
|
if len(prov.calls) != 0 {
|
|
t.Fatalf("the app was stopped despite the refusal, got %v", prov.calls)
|
|
}
|
|
}
|
|
|
|
// TestReconstituteNoDBAppMakesNoDumpOrImportCalls is Scenario E: an app without a database must flow
|
|
// exactly as before — no safety dump, no replay — so the new leg cannot regress the simple case.
|
|
func TestReconstituteNoDBAppMakesNoDumpOrImportCalls(t *testing.T) {
|
|
m, _, imported := reconFixture(t, "run1", "2026-07-19T06:00:00Z", "")
|
|
m.discoverDBs = func(context.Context) ([]DiscoveredDB, error) { return nil, nil }
|
|
dumped := 0
|
|
m.SetSafetyDumpFn(func(_ context.Context, d DiscoveredDB, _ string) DumpResult {
|
|
dumped++
|
|
return DumpResult{DB: d}
|
|
})
|
|
|
|
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
|
if err != nil {
|
|
t.Fatalf("reconstitute: %v", err)
|
|
}
|
|
if dumped != 0 {
|
|
t.Fatalf("a no-DB app must not produce a safety dump, got %d call(s)", dumped)
|
|
}
|
|
if len(*imported) != 0 {
|
|
t.Fatalf("a no-DB app must not import anything, got %v", *imported)
|
|
}
|
|
if res.SafetyDump != "" || res.DBsReplayed != 0 {
|
|
t.Fatalf("unexpected DB activity: safety=%q replayed=%d", res.SafetyDump, res.DBsReplayed)
|
|
}
|
|
}
|
|
|
|
// TestReconstituteSurfacesLegacySkewedPair is Scenario D: a pre-v0.148 snapshot carries no coherence
|
|
// stamp, so its two halves may be from different times. That must be SURFACED (and reversible), never
|
|
// blocked — the customer's own judgement is the gate, and refusing would deny a legitimate restore.
|
|
func TestReconstituteSurfacesLegacySkewedPair(t *testing.T) {
|
|
m, _, imported := reconFixture(t, "", "", pgDump(1))
|
|
|
|
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
|
if err != nil {
|
|
t.Fatalf("a legacy pair must still be restorable, got refusal: %v", err)
|
|
}
|
|
if !res.Skewed {
|
|
t.Fatal("an unstamped (pre-v0.148) snapshot must report Skewed so the confirm can say so")
|
|
}
|
|
if len(*imported) != 1 {
|
|
t.Fatalf("the legacy restore must still replay, got %v", *imported)
|
|
}
|
|
}
|
|
|
|
// TestReconstituteFlagsCustomerEmptyDump is the R-44 sniff at the restore end: the immich dump that
|
|
// started all of this was structurally valid and contained zero users. Restoring it is allowed, but
|
|
// the customer must be told before they commit.
|
|
func TestReconstituteFlagsCustomerEmptyDump(t *testing.T) {
|
|
// A valid postgres dump whose accounts table has NO rows — the 2026-07-19 shape exactly.
|
|
m, _, _ := reconFixture(t, "run1", "2026-07-19T06:00:00Z", pgDump(0))
|
|
|
|
res, err := m.ReconstituteFromOffsite(context.Background(), "immich")
|
|
if err != nil {
|
|
t.Fatalf("the sniff must never block a restore: %v", err)
|
|
}
|
|
if !res.LooksEmpty {
|
|
t.Fatal("a dump with an empty accounts table must raise the warn-level signal")
|
|
}
|
|
}
|
|
|
|
// TestOffsiteScratchPairReportsWhatTheConfirmNeeds covers the page-render surface: the confirm can
|
|
// only be honest if this reports the pair's age and warnings before anything is started.
|
|
func TestOffsiteScratchPairReportsWhatTheConfirmNeeds(t *testing.T) {
|
|
m, _, _ := reconFixture(t, "run1", "2026-07-19T06:00:00Z",
|
|
"-- PostgreSQL database dump\nCREATE TABLE a();\nCOPY public.\"user\" (id) FROM stdin;\n7\n\\.\n")
|
|
|
|
info := m.OffsiteScratchPair("immich")
|
|
if !info.Ready || !info.HasDump {
|
|
t.Fatalf("expected a ready pair with a dump, got %+v", info)
|
|
}
|
|
if info.Skewed {
|
|
t.Fatal("a stamped snapshot must not be reported as skewed")
|
|
}
|
|
if info.LooksEmpty {
|
|
t.Fatal("a dump with account rows must not be flagged empty")
|
|
}
|
|
want, _ := time.Parse(time.RFC3339, "2026-07-19T06:00:00Z")
|
|
if !info.DumpsAt.Equal(want) {
|
|
t.Fatalf("DumpsAt = %v, want %v", info.DumpsAt, want)
|
|
}
|
|
}
|
|
|
|
// --- R-44: the coherence pre-phase -----------------------------------------------------------
|
|
|
|
// TestOffsiteRunDumpsBeforeCapture is Scenarios A + B. The ORDER is the entire mechanism: dumps
|
|
// must be refreshed BEFORE restic captures, so the snapshot pairs this run's database with this
|
|
// run's files. Reversed, the snapshot would hold rows pointing at files that were never captured.
|
|
//
|
|
// It also asserts the ordering on the NIGHTLY entry point (RunOffboxBackup, no progress sink), not
|
|
// just the manual one — before v0.148.0 the nightly ordering was an accident of two independent
|
|
// scheduler entries at 02:30 and 04:15, which a schedule edit could silently invert.
|
|
func TestOffsiteRunDumpsBeforeCapture(t *testing.T) {
|
|
drive := t.TempDir()
|
|
m, sett, prov := classifiedOffboxManager(t, drive)
|
|
mkUnit(t, drive, "immich")
|
|
if err := os.MkdirAll(filepath.Join(drive, "appdata", "immich"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prov.hdd["immich"] = drive
|
|
prov.has["immich"] = true
|
|
prov.binds["immich"] = []ClassifiedBind{mandatoryHDD("appdata/immich")}
|
|
_ = sett.SetAppOffbox("immich", true)
|
|
|
|
var order []string
|
|
m.SetOffsitePreDumpFn(func(context.Context) error {
|
|
order = append(order, "dump")
|
|
return nil
|
|
})
|
|
m.SetOffboxRunner(func(_ context.Context, _ []string, args ...string) ([]byte, error) {
|
|
switch {
|
|
case contains(args, "cat") && contains(args, "config"):
|
|
return []byte(`{"version":2}`), nil
|
|
case contains(args, "backup"):
|
|
order = append(order, "capture")
|
|
return nil, nil
|
|
case contains(args, "snapshots"):
|
|
return []byte(`[]`), nil
|
|
case contains(args, "stats"):
|
|
return []byte(`{"total_size":123}`), nil
|
|
}
|
|
return nil, nil
|
|
})
|
|
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("run: %v", err)
|
|
}
|
|
if len(order) < 2 {
|
|
t.Fatalf("expected both a dump and a capture, got %v", order)
|
|
}
|
|
if order[0] != "dump" {
|
|
t.Fatalf("the dump leg MUST precede the capture (R-44); got %v", order)
|
|
}
|
|
if order[1] != "capture" {
|
|
t.Fatalf("expected the capture immediately after the dump, got %v", order)
|
|
}
|
|
}
|
|
|
|
// TestOffsiteRunContinuesWhenDumpLegFails is the data-first rule: a dump failure degrades the
|
|
// snapshot's DB half but must NOT abort the push. Refusing to ship the files would turn a partial
|
|
// backup into no backup at all — strictly worse for the customer.
|
|
func TestOffsiteRunContinuesWhenDumpLegFails(t *testing.T) {
|
|
drive := t.TempDir()
|
|
m, sett, prov := classifiedOffboxManager(t, drive)
|
|
mkUnit(t, drive, "immich")
|
|
if err := os.MkdirAll(filepath.Join(drive, "appdata", "immich"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
prov.hdd["immich"] = drive
|
|
prov.has["immich"] = true
|
|
prov.binds["immich"] = []ClassifiedBind{mandatoryHDD("appdata/immich")}
|
|
_ = sett.SetAppOffbox("immich", true)
|
|
|
|
m.SetOffsitePreDumpFn(func(context.Context) error { return context.DeadlineExceeded })
|
|
cap := &backupCapture{}
|
|
m.SetOffboxRunner(cap.runner())
|
|
|
|
if err := m.RunOffboxBackup(context.Background()); err != nil {
|
|
t.Fatalf("a dump failure must not fail the whole run: %v", err)
|
|
}
|
|
if cap.backups != 1 {
|
|
t.Fatalf("the files must still be pushed after a dump failure, got %d capture(s)", cap.backups)
|
|
}
|
|
}
|
|
|
|
// TestCaptureRecoveryUnitStampsAndCarriesRunID covers the stamp that makes a pair verifiable at
|
|
// restore time, and the trap beside it: the PERIODIC refresh must neither invent a coherence claim
|
|
// nor erase one a real run established.
|
|
func TestCaptureRecoveryUnitStampsAndCarriesRunID(t *testing.T) {
|
|
drive := t.TempDir()
|
|
m, _, base := classifiedOffboxManager(t, drive)
|
|
base.hdd["immich"] = drive
|
|
// CaptureRecoveryUnit needs real recovery info + a compose dir to read; the shared fixture
|
|
// provider returns none, so wrap it rather than widening a struct four other test files use.
|
|
stackDir := filepath.Join(t.TempDir(), "immich")
|
|
if err := os.MkdirAll(stackDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(stackDir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
m.SetStackProvider(&recoveryProvider{offbox3aProvider: *base, stackDir: stackDir})
|
|
|
|
// 1) A run in flight stamps the manifest.
|
|
end := m.beginOffsiteRunStamp("run-A")
|
|
if err := m.CaptureRecoveryUnit("immich"); err != nil {
|
|
t.Fatalf("capture: %v", err)
|
|
}
|
|
end()
|
|
man := readManifest(RecoveryUnitManifestPath(drive, "immich"))
|
|
if man == nil || man.OffsiteRunID != "run-A" {
|
|
t.Fatalf("expected the in-flight run id to be stamped, got %+v", man)
|
|
}
|
|
if man.DumpsAt == "" {
|
|
t.Fatal("a stamped unit must record when its dumps were taken")
|
|
}
|
|
|
|
// 2) A periodic refresh (no run in flight) must CARRY the stamp forward, not blank it — a unit
|
|
// that silently lost its stamp would be re-reported as a skewed legacy pair at restore time.
|
|
if err := m.CaptureRecoveryUnit("immich"); err != nil {
|
|
t.Fatalf("refresh: %v", err)
|
|
}
|
|
man2 := readManifest(RecoveryUnitManifestPath(drive, "immich"))
|
|
if man2 == nil || man2.OffsiteRunID != "run-A" {
|
|
t.Fatalf("the periodic refresh erased the coherence stamp: %+v", man2)
|
|
}
|
|
|
|
// 3) A NEW run re-stamps even though nothing else about the unit changed — the idempotent-skip
|
|
// must not swallow the one field the restore path reads.
|
|
end2 := m.beginOffsiteRunStamp("run-B")
|
|
if err := m.CaptureRecoveryUnit("immich"); err != nil {
|
|
t.Fatalf("capture 2: %v", err)
|
|
}
|
|
end2()
|
|
man3 := readManifest(RecoveryUnitManifestPath(drive, "immich"))
|
|
if man3 == nil || man3.OffsiteRunID != "run-B" {
|
|
t.Fatalf("a new run must re-stamp the unit, got %+v", man3)
|
|
}
|
|
}
|