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:
@@ -815,6 +815,17 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
data["OffboxScratchReady"] = ready
|
||||
// R-43: the same prepared scratch also enables the TRUE restore (files + database). Its confirm
|
||||
// has to state what the pair actually IS — how old the DB half is, whether the two halves even
|
||||
// come from the same run, and whether the dump looks customer-empty — because a restore is the
|
||||
// one operation whose result the customer cannot inspect until after committing to it.
|
||||
pairs := map[string]backup.OffsitePairInfo{}
|
||||
if s.backupMgr != nil {
|
||||
for name := range ready {
|
||||
pairs[name] = s.backupMgr.OffsiteScratchPair(name)
|
||||
}
|
||||
}
|
||||
data["OffboxPairInfo"] = pairs
|
||||
// R-7b: the shares source is not an app — it has no per-app toggle and no recovery unit — so it
|
||||
// gets its own restore entry rather than a synthetic row in OffboxApps (which would also make it
|
||||
// appear in the per-app offsite TOGGLE list on /backups/remote, where it does not belong).
|
||||
|
||||
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@@ -340,6 +341,69 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A távoli visszaállítás elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
// offboxReconstituteHandler is the TRUE offsite restore (R-43, v0.148.0): files overwritten to the
|
||||
// snapshot's version + that same snapshot's database replayed + the app restarted, with a safety
|
||||
// dump of the current database taken first.
|
||||
//
|
||||
// It is a separate button from the missing-only place, not a flag on it. The two do opposite things
|
||||
// to an existing file, and the v0.147 flash („hiányzó fájljai helyreállítva") described a mechanism
|
||||
// that could report success after merging zero files while the customer's photos stayed invisible.
|
||||
// The flash here states the OUTCOME instead — file count, database, backup timestamp, restart —
|
||||
// because that is the only part the customer can check against what they see in the app.
|
||||
func (s *Server) offboxReconstituteHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A távoli mentési cél nincs beállítva.", true)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
app := strings.TrimSpace(r.FormValue("app"))
|
||||
if app == "" {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Hiányzó alkalmazás.", true)
|
||||
return
|
||||
}
|
||||
// This one overwrites live files and replays a database — it must never happen on a stray click.
|
||||
if r.FormValue("confirm") != "1" {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A teljes visszaállítás megerősítés nélkül nem hajtható végre.", true)
|
||||
return
|
||||
}
|
||||
if s.backupMgr.IsRunning() {
|
||||
offboxRedirectTo(w, r, "/backups/restore", "Egy mentési/visszaállítási művelet már fut.", true)
|
||||
return
|
||||
}
|
||||
s.backupMgr.BeginRestoreOp("offbox-reconstitute", app)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
res, err := s.backupMgr.ReconstituteFromOffsite(ctx, app)
|
||||
if err != nil {
|
||||
s.logger.Printf("[ERROR] [web] off-box reconstitute %s (async): %v", app, err)
|
||||
s.backupMgr.EndRestoreOp(false, "A teljes visszaállítás sikertelen: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.logger.Printf("[INFO] [web] off-box reconstitute %s completed (async): files=%d dbs=%d snapshot=%s",
|
||||
app, res.FilesPlaced, res.DBsReplayed, res.SnapshotID)
|
||||
s.backupMgr.EndRestoreOp(true, reconstituteOutcomeMsg(app, res))
|
||||
}()
|
||||
offboxRedirectTo(w, r, "/backups/restore", "A teljes visszaállítás elindult — az állapot itt frissül.", false)
|
||||
}
|
||||
|
||||
// reconstituteOutcomeMsg builds the OUTCOME flash for a completed reconstitution. Pure, so the
|
||||
// wording is unit-testable — this string is the customer's only evidence that the operation did
|
||||
// what its label promised, and the zero-file and no-database cases must each read truthfully rather
|
||||
// than borrowing the confident sentence that belongs to the full case.
|
||||
func reconstituteOutcomeMsg(app string, res backup.OffsiteReconstituteResult) string {
|
||||
when := ""
|
||||
if !res.DumpsAt.IsZero() {
|
||||
when = " (mentés: " + res.DumpsAt.In(getTimezone()).Format("2006-01-02 15:04") + ")"
|
||||
}
|
||||
if res.DBsReplayed == 0 {
|
||||
// A no-database app: saying "és az adatbázis" here would be a lie, and this is precisely the
|
||||
// class of sentence the DIAG found being printed over a no-op.
|
||||
return fmt.Sprintf("A(z) %s: %d fájl visszaállítva%s — az alkalmazás újraindult. Ennek az alkalmazásnak nincs adatbázisa.", app, res.FilesPlaced, when)
|
||||
}
|
||||
return fmt.Sprintf("A(z) %s: %d fájl és az adatbázis visszaállítva%s — az alkalmazás újraindult.", app, res.FilesPlaced, when)
|
||||
}
|
||||
|
||||
// offboxVerifyCopyDeleteHandler removes ONE verification copy (v0.147.0, 4a).
|
||||
//
|
||||
// The only delete this slice adds, so it is deliberately narrow: it names a STACK, never a path — the
|
||||
|
||||
@@ -422,6 +422,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.offboxRestoreHandler(w, r)
|
||||
case path == "/backup/offbox/place" && r.Method == http.MethodPost:
|
||||
s.offboxPlaceHandler(w, r)
|
||||
case path == "/backup/offbox/reconstitute" && r.Method == http.MethodPost:
|
||||
s.offboxReconstituteHandler(w, r)
|
||||
// v0.147.0 (4a): remove ONE verification copy. The only delete path this slice adds — see
|
||||
// DeleteOffsiteRestoreCopy for the prefix guard.
|
||||
case path == "/backup/offbox/verify-copy/delete" && r.Method == http.MethodPost:
|
||||
|
||||
@@ -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(); })
|
||||
|
||||
Reference in New Issue
Block a user