feat(backup): async restore family — no proxy-timeout error page on a succeeding restore (v0.102.0)

Re-adjudicates F4: /backup/restore, /backup/tier2/restore, /backup/offbox/restore
blocked the HTTP request until completion, so through cloudflared's 100s cap a
customer got an error page while the restore succeeded (offbox worse — bounded
on r.Context(), canceling the SFTP restore mid-flight). Convert all three to the
offboxRun async shape: fast-path IsRunning refuse, background goroutine
(offbox ctx off r.Context() -> Background+30m), instant redirect. Add mutex-
guarded op-status (opstatus.go) + GET /api/backup/restore-status + a 3s-polling
backups.html banner (neutral running, red on failure). Restore single-flight
unchanged. Tests + red-proof (sync handler blocks indefinitely vs <500ms async).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-06 20:23:49 +02:00
parent 9d5a588ca3
commit c529a455af
9 changed files with 429 additions and 41 deletions
+40 -34
View File
@@ -871,29 +871,28 @@ func (s *Server) backupRestoreHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Restore requested: stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
start := time.Now()
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed on
// an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
err := s.backupMgr.RestoreFromRecoveryUnit(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Restore failed: %v", err)
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s failed after %s", stackName, time.Since(start))
}
errMsg := url.QueryEscape("Visszaállítás sikertelen: " + err.Error())
http.Redirect(w, r, "/backups?flash_error="+errMsg, http.StatusFound)
// Part B: restore is a long SYNCHRONOUS op (F4 — through cloudflared's hard 100s cap the customer
// got an error page while it silently succeeded). Fast-path refuse a concurrent op, then run it in
// a BACKGROUND goroutine (survives the request; the poll banner shows progress → result).
if s.backupMgr.IsRunning() {
http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
return
}
if s.isDebug() {
s.logger.Printf("[DEBUG] [web] backupRestoreHandler: stack=%s completed in %s", stackName, time.Since(start))
}
msg := url.QueryEscape(stackName + " visszaállítva (" + snapshotID + ").")
http.Redirect(w, r, "/backups?flash="+msg, http.StatusFound)
s.logger.Printf("[WARN] [web] Restore requested (async): stack=%s, snapshot=%s from %s", stackName, snapshotID, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("restore", stackName)
go func() {
start := time.Now()
// Phase 2b: restore from the app's recovery unit (recovers secrets from the guest, fail-closed
// on an unrecoverable data-encrypting key; falls back to volume-only restore if no unit exists).
if err := s.backupMgr.RestoreFromRecoveryUnit(stackName); err != nil {
s.logger.Printf("[ERROR] [web] Restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Visszaállítás sikertelen: "+err.Error())
return
}
s.logger.Printf("[INFO] [web] Restore completed (async): stack=%s in %s", stackName, time.Since(start))
s.backupMgr.EndRestoreOp(true, stackName+" visszaállítva ("+snapshotID+").")
}()
http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}
// backupTier2RestoreHandler (C2, closes F2) restores an app's MISSING user files in place from its
@@ -917,21 +916,28 @@ func (s *Server) backupTier2RestoreHandler(w http.ResponseWriter, r *http.Reques
http.Redirect(w, r, "/backups?flash_error=Ment%C3%A9s+nincs+be%C3%A1ll%C3%ADtva", http.StatusFound)
return
}
s.logger.Printf("[WARN] [web] Tier-2 file restore requested: stack=%s from %s", stackName, r.RemoteAddr)
n, err := s.backupMgr.RestoreTier2Files(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed: %v", err)
http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Fájl-visszaállítás sikertelen: "+err.Error()), http.StatusFound)
// Part B (same async shape as backupRestoreHandler): fast-path refuse, then background goroutine.
if s.backupMgr.IsRunning() {
http.Redirect(w, r, "/backups?flash_error="+url.QueryEscape("Egy mentési/visszaállítási művelet már fut."), http.StatusFound)
return
}
msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
if n > 0 {
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
}
http.Redirect(w, r, "/backups?flash="+url.QueryEscape(msg), http.StatusFound)
s.logger.Printf("[WARN] [web] Tier-2 file restore requested (async): stack=%s from %s", stackName, r.RemoteAddr)
s.backupMgr.BeginRestoreOp("tier2-restore", stackName)
go func() {
n, err := s.backupMgr.RestoreTier2Files(stackName)
if err != nil {
s.logger.Printf("[ERROR] [web] Tier-2 file restore failed (async): stack=%s: %v", stackName, err)
s.backupMgr.EndRestoreOp(false, "Fájl-visszaállítás sikertelen: "+err.Error())
return
}
msg := "Nincs hiányzó fájl — minden fájl megvan a helyén."
if n > 0 {
msg = fmt.Sprintf("%s: %d fájl visszaállítva a másodlagos másolatból.", stackName, n)
}
s.logger.Printf("[INFO] [web] Tier-2 file restore completed (async): stack=%s (%d files)", stackName, n)
s.backupMgr.EndRestoreOp(true, msg)
}()
http.Redirect(w, r, "/backups?flash="+url.QueryEscape("Fájl-visszaállítás elindult — az állapot itt frissül."), http.StatusFound)
}
// settingsBaseData is the shared identity block used by every settings-family subpage