v0.155.0 — the restore wizard read the wrong "is something running" flag

Fixes a defect shipped in v0.154.0, found by the operator on the first live
click-through of the new wizard.

backup.Manager carries TWO running booleans. `running` (read by IsRunning) is the
concurrency single-flight, acquired inside the background goroutine — and
RestoreOffboxScratch never acquires it at all. `opRunning` (read by RestoreStatus) is the
display flag, set synchronously by BeginRestoreOp in the handler.

The wizard sourced OpRunning from IsRunning(), so for „Ellenőrzés" and the full-restore
preparation — its two most-used and longest actions, both streaming from restic — the
execution step was unreachable: the page offered all three intents with live buttons
while a restore was running, and the progress banner contradicted the phase strip on the
same screen. Pressing anything there would have been refused by the handler, which is the
exact "offering a control guaranteed to fail" dishonesty R-48 exists to remove.

Fix: restoreOpInFlight(st) behind a documented seam, fed by a SINGLE RestoreStatus() read
per render so the strip, the suppression decision and the running-op name cannot diverge.

Why the tests missed it: the Scenario-E table proved deriveWizardStep behaves correctly
GIVEN OpRunning=true, but nothing proved the handler ever computes true — hollow at
exactly that seam. TestRestoreOpInFlight_UsesDisplayFlagNotConcurrencyFlag now drives a
real Manager through BeginRestoreOp and asserts the render suppresses every form.
Red-proofed against the v0.154.0 shape.

Also: „Eredmény" was a dead label. The strip's highlight is now its own derived Phase,
separate from Step — a finished restore returns to the intent step (everything available
again) while the strip reads „Eredmény" and an outcome card shows the result. Bounded by
restoreResultWindow (10 min) so a stale result cannot look fresh, and bound to the app so
a finished bookstack restore does not light immich's page with bookstack's message. The
card survives a reload; the redirect flash does not.

No new agent coupling — MinAgent stays 0.90.0.
This commit is contained in:
2026-07-21 09:15:56 +02:00
parent 70cb21b058
commit 9d1b4983f5
7 changed files with 320 additions and 21 deletions
+85 -9
View File
@@ -4,6 +4,7 @@ import (
"net/http"
"net/url"
"strings"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
@@ -53,8 +54,12 @@ const (
type restoreWizardInput struct {
// App is the app this wizard page is for.
App string
// OpRunning is true when ANY backup/restore op is in flight — not just this app's. The
// single-flight is process-wide, so a restore running for app X must suppress app Y's controls.
// OpRunning is true when ANY backup/restore op is in flight — not just this app's. The op status
// is process-wide, so a restore running for app X must suppress app Y's controls.
//
// Source it via restoreOpInFlight (the DISPLAY flag), never Manager.IsRunning() — see the note
// on that helper. Reading the wrong flag makes this field silently always-false for the
// verification restore, which is the wizard's most-used path.
OpRunning bool
// ScratchReady is true when a completed full-restore scratch exists for App. Both the
// missing-only merge and the true reconstitution require one.
@@ -62,6 +67,10 @@ type restoreWizardInput struct {
// FullPrepApp is the app the size-gate flash binds to (from ?full_prep=). It binds to ONE app:
// a prepare for X must not reveal a confirm on Y's page.
FullPrepApp string
// HasRecentResult is true when THIS app's restore finished a short while ago — see
// hasRecentRestoreResult. It moves the phase strip to „Eredmény"; it never changes what the
// customer may do (a finished restore leaves every intent available again).
HasRecentResult bool
}
// restoreWizardView is what the template renders. The enabled-flags are part of the derivation (not
@@ -69,6 +78,11 @@ type restoreWizardInput struct {
// one pure function with one test table.
type restoreWizardView struct {
Step restoreWizardStep
// Phase is which label the phase strip highlights. It is NOT the same as Step: a finished restore
// is back on the intent step (everything is offered again) while the strip rightly says
// „Eredmény". Keeping them separate is what stopped the strip from having to lie in one direction
// or the other.
Phase restoreWizardPhase
// VerifyEnabled — intent 1: restore into a separate verification folder (mode=unit). Live data
// is untouched, so this is the only intent available without a prepared scratch.
VerifyEnabled bool
@@ -85,6 +99,35 @@ type restoreWizardView struct {
CommitPrepareEnabled bool
}
// restoreWizardPhase is the phase-strip highlight. Four labels, all reachable.
type restoreWizardPhase string
const (
wizPhasePrepare restoreWizardPhase = "elokeszites"
wizPhaseConfirm restoreWizardPhase = "megerosites"
wizPhaseExecute restoreWizardPhase = "vegrehajtas"
wizPhaseResult restoreWizardPhase = "eredmeny"
)
// restoreResultWindow bounds how long after a finished restore the strip still says „Eredmény".
// Without a bound the last result would light that phase forever — landing on the page a week later
// would claim you had just finished a restore. Same reasoning as escrowCeremonyGraceWindow; shorter,
// because this answers "what just happened", not "are we still waiting".
const restoreResultWindow = 10 * time.Minute
// hasRecentRestoreResult reports whether THIS app has a just-finished restore to show. Pure (the
// clock is a parameter) so the boundary and the wrong-app case are table-testable.
//
// Bound to the app on purpose: the op status is process-wide, so a finished bookstack restore must
// not light „Eredmény" on immich's wizard and show bookstack's message there.
func hasRecentRestoreResult(st backup.RestoreOpStatus, app string, now time.Time) bool {
if st.Running || st.Last == nil || st.Last.Stack != app || st.Last.FinishedAt.IsZero() {
return false
}
d := now.Sub(st.Last.FinishedAt)
return d >= 0 && d < restoreResultWindow
}
// deriveWizardStep is the Scenario-B truth table: step and available intents are a PURE function of
// the state, in strict precedence order.
//
@@ -98,13 +141,18 @@ type restoreWizardView struct {
// never resurrect a commit button while a restore is mid-flight.
func deriveWizardStep(in restoreWizardInput) restoreWizardView {
if in.OpRunning {
return restoreWizardView{Step: wizStepExecution}
return restoreWizardView{Step: wizStepExecution, Phase: wizPhaseExecute}
}
if in.FullPrepApp != "" && in.FullPrepApp == in.App {
return restoreWizardView{Step: wizStepPrepareConfirm, CommitPrepareEnabled: true}
return restoreWizardView{Step: wizStepPrepareConfirm, Phase: wizPhaseConfirm, CommitPrepareEnabled: true}
}
phase := wizPhasePrepare
if in.HasRecentResult {
phase = wizPhaseResult
}
return restoreWizardView{
Step: wizStepIntent,
Phase: phase,
VerifyEnabled: true,
PrepareEnabled: !in.ScratchReady,
PlaceEnabled: in.ScratchReady,
@@ -129,6 +177,28 @@ func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow {
return nil
}
// restoreOpInFlight reports whether a restore op is in flight, FOR DISPLAY.
//
// **Use this, not `Manager.IsRunning()`.** The Manager carries two different booleans and they are
// not interchangeable:
//
// - `m.running` (read by `IsRunning`) is the CONCURRENCY single-flight. It is acquired *inside*
// the restore function, on the background goroutine — and `RestoreOffboxScratch` never acquires
// it at all. So for the verification restore and the full-restore preparation — the wizard's two
// most-used actions, and the long ones, since they stream from restic — `IsRunning()` is false
// for the entire operation.
// - `m.opRunning` (read by `RestoreStatus`) is the DISPLAY flag, set synchronously by
// `BeginRestoreOp` in the handler *before* the goroutine launches and cleared by `EndRestoreOp`.
// It covers all four offsite actions with no start-up window.
//
// v0.154.0 shipped with `IsRunning()` here, which made the execution step unreachable for
// `RestoreOffboxScratch`: the page offered all three intents, with live buttons, while a restore was
// downloading — and the progress banner (which polls the op status) contradicted it on the same
// screen. Caught by the operator on the first live click-through.
func restoreOpInFlight(st backup.RestoreOpStatus) bool {
return st.Running
}
// backupsRestoreWizardHandler renders GET /backups/restore/app?name=<app> — the single entry the
// list page now offers per app.
//
@@ -154,11 +224,13 @@ func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Requ
data := s.backupsCommonData("backups-restore", "Visszaállítás — "+row.DisplayName, r)
st := s.backupMgr.RestoreStatus()
in := restoreWizardInput{
App: app,
OpRunning: s.backupMgr.IsRunning(),
ScratchReady: s.backupMgr.OffboxFullScratchReady(app),
FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")),
App: app,
OpRunning: restoreOpInFlight(st),
ScratchReady: s.backupMgr.OffboxFullScratchReady(app),
FullPrepApp: strings.TrimSpace(r.URL.Query().Get("full_prep")),
HasRecentResult: hasRecentRestoreResult(st, app, time.Now()),
}
view := deriveWizardStep(in)
@@ -177,8 +249,12 @@ func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Requ
}
// The running op's identity, so the execution card can say WHAT is running rather than a bare
// "please wait" — including the case where it belongs to a different app.
st := s.backupMgr.RestoreStatus()
data["RunningStack"] = st.Stack
// The outcome card for the „Eredmény" phase — the same message the redirect flash carried, but it
// survives a reload, which the flash does not.
if in.HasRecentResult {
data["LastResult"] = st.Last
}
s.executeTemplate(w, r, "backups_restore_wizard", data)
}