v0.148.0 — coherent snapshot pairs + an offsite restore that actually restores (R-43 + R-44)

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
This commit is contained in:
2026-07-19 12:21:05 +02:00
parent 2fcae041ae
commit 062357f778
20 changed files with 1486 additions and 154 deletions
@@ -215,6 +215,16 @@
/* A run is not only the per-app loop: the shares leg and the retention/prune step follow
it and can dominate the wall clock (40 of 57 seconds, measured). Name them, or the
card freezes on the last app's finished counters for that whole tail. */
/* The dump pre-phase runs BEFORE the per-app loop (R-44): it refreshes every app's
database dump so the snapshot pairs this run's files with this run's DB. On an app
with a large database it can dominate the early wall clock, and an unnamed silent
stretch at the start reads as a hang. */
if(p.phase === 'dump'){
text.textContent = 'Adatbázisok mentése a pillanatképhez…';
bar.style.width = '100%';
box.className = 'alert alert-info'; box.style.display = '';
return;
}
if(p.phase === 'retention'){
text.textContent = 'Karbantartás: régi mentések rendezése a távoli tárolón…';
bar.style.width = '100%';
@@ -93,7 +93,25 @@
<input type="hidden" name="app" value="{{.Name}}">
<button type="submit" class="btn btn-xs btn-outline">Helyreállítás az élő adatok közé (csak a hiányzó fájlok)</button>
</form>
<span class="form-hint" style="display:block;margin-top:.25rem">A meglévő fájlokat nem írja felül.</span>
<span class="form-hint" style="display:block;margin-top:.25rem">A meglévő fájlokat nem írja felül. Adatbázist nem állít vissza — törölt tartalom ettől nem jelenik meg újra.</span>
{{$pair := index $.OffboxPairInfo .Name}}
<form method="POST" action="/backup/offbox/reconstitute" style="display:inline">{{$.CSRFField}}
<input type="hidden" name="app" value="{{.Name}}">
<input type="hidden" name="confirm" value="1">
<button type="button" class="btn btn-xs btn-primary"
data-restore-app="{{.Name}}"
data-restore-when="{{if not $pair.DumpsAt.IsZero}}{{fmtTime $pair.DumpsAt}}{{end}}"
data-restore-skewed="{{if $pair.Skewed}}1{{end}}"
data-restore-empty="{{if $pair.LooksEmpty}}1{{end}}"
onclick="confirmFullRestore(this)">Teljes visszaállítás (fájlok + adatbázis)</button>
</form>
<span class="form-hint" style="display:block;margin-top:.25rem">A fájlokat a mentés szerinti változatra állítja vissza és az adatbázist is visszatölti. Semmit nem töröl: a mentés óta létrejött fájlok megmaradnak. A jelenlegi adatbázisról előtte biztonsági mentés készül.</span>
{{if $pair.Skewed}}
<span class="form-hint" style="display:block;margin-top:.25rem;color:var(--warn)">Az adatbázis-mentés régebbi{{if not $pair.DumpsAt.IsZero}} ({{fmtTime $pair.DumpsAt}}){{end}} — a fájlok és az adatbázis eltérő időpontból származnak.</span>
{{end}}
{{if $pair.LooksEmpty}}
<span class="form-hint" style="display:block;margin-top:.25rem;color:var(--warn)">A mentett adatbázis üresnek tűnik (nincs benne felhasználói fiók) — elképzelhető, hogy a mentés korábbi, mint az adataid.</span>
{{end}}
{{end}}{{end}}
{{template "app_list_row_end"}}
{{end}}
@@ -217,6 +235,26 @@ function confirmDeleteVerifyCopy(btn){
});
});
}
/* R-43: the true offsite restore overwrites live files and replays a database, so it double-confirms
and — unlike the old missing-only merge — states the DB half's age and any warning BEFORE the
customer commits. The honesty lines are already rendered under the button; repeating the decisive
ones here means the person clicking "Igen" has read them. */
function confirmFullRestore(btn){
var app = btn.getAttribute('data-restore-app') || '';
var when = btn.getAttribute('data-restore-when') || '';
var skewed = btn.getAttribute('data-restore-skewed') === '1';
var empty = btn.getAttribute('data-restore-empty') === '1';
var q = 'Teljes visszaállítás: ' + app + (when ? ' — a mentés ideje: ' + when : '') + '.';
if (skewed) { q += ' FIGYELEM: a fájlok és az adatbázis eltérő időpontból származnak.'; }
if (empty) { q += ' FIGYELEM: a mentett adatbázis üresnek tűnik.'; }
q += ' A fájlok a mentés szerinti változatra állnak vissza, semmi nem törlődik.';
felhomConfirm(btn, q, function(){
felhomConfirm(btn, 'UTOLSÓ MEGERŐSÍTÉS: az alkalmazás leáll, az adatbázis visszatöltődik, majd újraindul. A jelenlegi adatbázisról biztonsági mentés készül.', function(){
var f = btn.closest('form');
if (f) { if (f.requestSubmit) f.requestSubmit(); else f.submit(); }
});
});
}
function fabStart(stack, next){
fetch('/api/export/download/start', {method:'POST', headers:Object.assign({'Content-Type':'application/json'}, csrfHeaders()), body: JSON.stringify({stack_name: stack, password: fabPassword()})})
.then(function(r){ return r.json(); })