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
+24
View File
@@ -1,5 +1,29 @@
## Changelog
### v0.102.0 — async restore family: no more proxy-timeout error page on a succeeding restore (2026-07-06)
Re-adjudicates campaign **F4** (`felhom.eu/documentation/audits/RERUN-p1p3-2026-07-06.md`): all three
restore surfaces (`/backup/restore`, `/backup/tier2/restore`, `/backup/offbox/restore`) blocked the
HTTP request until completion. Through cloudflared's hard 100s cap + traefik, a real customer got an
**error page while the restore silently succeeded**; the off-box one was worse — it bounded on
`r.Context()`, so a proxy read-timeout **canceled the SFTP restore mid-flight**.
- **Async family** (mirrors the existing `offboxRunHandler` shape): each handler fast-path refuses a
concurrent op (`IsRunning()` → "Egy mentési/visszaállítási művelet már fut."), then runs the restore
in a **background goroutine** and redirects immediately with a "Visszaállítás elindult…" flash. The
offbox restore's context moved from `r.Context()` to `context.Background()+30m` (fixes the mid-flight
cancel). The restore functions' internal single-flight acquire is unchanged.
- **Op-status surface** (`internal/backup/opstatus.go`): mutex-guarded in-memory current-op + terminal
`last{op,stack,ok,message,finished_at}`, deep-copy getter; new `GET /api/backup/restore-status`
(distinct from `/backup/status`, which proxies the agent's PBS status). In-memory, lost on restart
(same precedent as notification cooldowns).
- **UI** (`backups.html`): a progress banner polls the status every 3s — neutral while running (shown
even on a fresh page load mid-op), success on completion, red **only** on failure.
- Tests: `opstatus_test.go` (begin→running→terminal, deep-copy, failure); `async_restore_test.go`
(handler returns <500ms while the restore parks in a blocking provider + op-status transitions;
double-click refused with no second launch). Red-proof: the pre-fix synchronous handler blocks the
request indefinitely (test killed at 30s) vs <500ms async.
### v0.101.0 — campaign findings F3 (sync deadline) + F2 evidence gap (agent refusal surfacing) (2026-07-06)
From the no-mercy campaign (`felhom.eu/documentation/audits/CAMPAIGN-nomercy-2026-07-06.md`).