v0.204.0 — the restore list is keyed on the store (R-237); the size gate stops refusing in silence (R-238)
gates / gates (push) Successful in 26s

R-237: /backups/restore listed apps that are CURRENTLY DEPLOYED and CURRENTLY
TOGGLED ON for future off-site backups. A rebuilt box has neither, so a household
that had just lost everything was shown nothing to restore while the repository
held their snapshots — measured live on the R-201 re-walk. To restore an app you
had to select it, to select it you had to have installed it, and to know what to
install you had to see the backup you could not see.

The store is now the source of the list (offsite_restore_list.go), built on the
existing R-193 OffsiteInventoryList. Installed-ness became a property OF a row,
never a filter on it. Every case is answered rather than hidden: a snapshot for an
app that is not installed is offered and says it will reinstall first; an installed
app with no snapshot is shown as having nothing; an unreadable store renders as
UNKNOWN (R-225's rule, one screen over) AND keeps the action, because "we could
not look" is not "there is nothing"; no-target is its own state. The felhom-offbox
and _shares marker tags are excluded from the app list.

R-238 classified as a HARNESS ARTIFACT: mode=full without confirm=1 is step 1 of a
deliberate two-step — it starts no job by design and redirects carrying
&full_prep=<app>, which deriveWizardStep requires to reveal the commit. A driver
that did not carry it forward landed back on the intent step. The operator's
browser run completed the same restore. The wizard's precedence rules were NOT
re-keyed: a stale ?full_prep= must never resurrect a commit button mid-restore.

The residue WAS real and is fixed: neither branch of that step wrote anything to
the log, so a refusal — including by the headroom gate — left no trace on the box.
Both branches now log, and so does the concurrent-op refusal.

resolveWizardApp is removed: it was dead once the gate moved, and its test pinned
the defect's behaviour (an untoggled app refused), which would have read as policy.

28 packages ok, 9/9 gates OK. Three red-proofs, each asserted to have applied.
This commit is contained in:
2026-08-06 16:44:05 +02:00
parent 4d349d1106
commit 53e9bf0224
13 changed files with 648 additions and 123 deletions
@@ -28,6 +28,12 @@ func splitTestData() map[string]interface{} {
"OffboxConfigured": true,
"OffboxApps": []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web", Enabled: true}},
"OffboxToggledCount": 1,
// R-237: the restore list is driven by the STORE. The happy fixture is "the app is in the
// repository AND installed here" — the pre-rebuild shape.
"OffsiteRestoreRows": []OffsiteRestoreRow{
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: true, Enabled: true},
},
"OffsiteStoreState": string(offsiteStoreKnown),
"OffboxQuotaPct": 0,
"GuestBackup": map[string]interface{}{"Available": false, "Note": "n/a"},
}
+7
View File
@@ -1007,6 +1007,13 @@ func (s *Server) backupsRestoreHandler(w http.ResponseWriter, r *http.Request) {
if s.backupMgr != nil {
data["OffsiteRestoreCopies"] = s.backupMgr.ListOffsiteRestoreCopies()
}
// R-237: the restore list is driven by the STORE, not by what is deployed and toggled. A rebuilt
// box has neither, and used to be shown nothing at all while its snapshots sat in the repository.
if s.backupMgr != nil {
rows, state := s.offsiteRestoreRows(r.Context())
data["OffsiteRestoreRows"] = rows
data["OffsiteStoreState"] = string(state)
}
s.executeTemplate(w, r, "backups_restore", data)
}
@@ -316,9 +316,18 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
defer cancel()
sizeHuman, err := s.backupMgr.OffboxRestorePrepareFull(pctx, app)
if err != nil {
// R-238: this leg starts no job, so it appears NOWHERE in the restore-op status. Until
// v0.204.0 it also logged nothing, which made a refused disaster restore — including a
// refusal by the headroom gate — completely invisible on the box: no error, no line, and
// a redirect that lands the customer back where they started. Diagnosing a silence is
// what this project has spent a fortnight removing.
s.logger.Printf("[WARN] [web] off-box full-restore preparation REFUSED for %s (no job started): %v", app, err)
offboxRedirectTo(w, r, restoreWizardPath(app), err.Error(), true)
return
}
// The success half is logged too: it is the step that decides the customer may proceed, and
// "the size gate passed at N" is the line that explains the confirm they were then shown.
s.logger.Printf("[INFO] [web] off-box full-restore prepared for %s (size %s) — awaiting the customer's confirm; no restore has started", app, sizeHuman)
// R-48: the size-gate reveal now lands on the app's wizard (prepare-confirm step) rather than
// on the list page. Same params, same meaning — only the surface that renders them changed.
http.Redirect(w, r, restoreWizardPath(app)+"&full_prep="+url.QueryEscape(app)+"&full_size="+url.QueryEscape(sizeHuman), http.StatusFound)
@@ -327,6 +336,8 @@ func (s *Server) offboxRestoreHandler(w http.ResponseWriter, r *http.Request) {
// Fast-path refuse a concurrent op, then run async on a BACKGROUND context (a proxy read-timeout on
// r.Context() would CANCEL the SFTP restore mid-flight — the F4 lesson).
if s.backupMgr.IsRunning() {
// Same silence class as the size gate: a refusal that starts nothing must still be findable.
s.logger.Printf("[WARN] [web] off-box restore refused for %s (mode=%s): another backup/restore op is already running", app, mode)
offboxRedirectTo(w, r, restoreWizardPath(app), "Egy mentési/visszaállítási művelet már fut.", true)
return
}
@@ -0,0 +1,72 @@
package web
import (
"bytes"
"context"
"errors"
"log"
"net/http/httptest"
"net/url"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// R-238 — the full-restore size gate must never refuse in silence.
//
// WHAT WAS MEASURED (2026-08-06, part4 venue). `POST /backup/offbox/restore` with `mode=full` and no
// `confirm=1` is step 1 of a deliberate two-step: it computes size + headroom, starts NO job, and
// redirects carrying `&full_prep=<app>` so the wizard reveals the commit. Driving it without
// carrying that parameter forward lands back on the intent step — which is correct behaviour, and is
// why the endpoint-level run looked like "the button does nothing".
//
// The REAL defect underneath, and the one this pins: neither branch of that step wrote anything to
// the controller's log. `restore-status` is empty by design (no job), the redirect is invisible, and
// `offboxRedirectTo` only flashes to the page — so a customer refused a disaster restore, INCLUDING
// a refusal by the headroom gate, left no trace on the box at all. "No error, no log line" is a
// diagnosis problem whoever triggers it.
//
// Handler-level on purpose: the silence was in the handler, and a helper-level assertion cannot
// observe it. That mistake has been made three times in this arc.
func TestOffboxRestore_FullPrepareRefusal_IsNotSilent(t *testing.T) {
s, sett, m := newOffboxWebServer(t)
if err := sett.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.local", Port: 22, User: "felhom", RepoPath: "/srv/repo",
Schedule: "daily", EscrowState: "escrowed",
}); err != nil {
t.Fatal(err)
}
if err := m.WriteOffboxSecrets("PRIVATE-KEY-MATERIAL", "nas.local ssh-ed25519 AAAAhostkey"); err != nil {
t.Fatal(err)
}
if !m.OffboxConfigured() {
t.Fatal("fixture: the target must be configured, or the handler exits before the size gate")
}
// Deterministic refusal: every restic call fails, so the size gate errors for a real reason
// instead of being skipped, and no network is involved.
m.SetOffboxRunner(func(ctx context.Context, env []string, args ...string) ([]byte, error) {
return nil, errors.New("repository unreachable")
})
var logbuf bytes.Buffer
s.logger = log.New(&logbuf, "", 0)
form := url.Values{"app": {"calibre-web"}, "mode": {"full"}}
req := httptest.NewRequest("POST", "/backup/offbox/restore", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
s.offboxRestoreHandler(w, req)
if w.Code != 302 {
t.Fatalf("the size gate redirects; got %d", w.Code)
}
got := logbuf.String()
// The CONSEQUENCE, not the mechanism: whatever the outcome, this step must be findable in the log.
if !strings.Contains(got, "full-restore preparation REFUSED") && !strings.Contains(got, "full-restore prepared") {
t.Errorf("the full-restore size gate wrote NOTHING to the log \u2014 that is R-238's residue.\nlog was: %q", got)
}
if !strings.Contains(got, "calibre-web") {
t.Errorf("the line must name the app it refused, got %q", got)
}
}
@@ -0,0 +1,185 @@
package web
import (
"context"
"errors"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-237 — what a customer may restore is decided by WHAT IS IN THE STORE, not by what happens to be
// installed.
//
// WHY THIS EXISTS. Until v0.204.0 the off-site restore list was `buildOffboxApps()` filtered on
// `.Enabled`: apps that are **currently deployed** AND **currently toggled on for future off-site
// backups**. Both halves are wrong for the one situation the list exists to serve. Measured on the
// R-201 re-walk (2026-08-06, `documentation/tests/part4-rewalk-2026-08-06/journal.md`): after a
// rebuild the box had no deployed stacks, so the page said „Nincs telepített alkalmazás" and the
// wizard refused every app with „Ez az alkalmazás nincs távoli mentésre kijelölve" — while the
// repository held their snapshots the whole time.
//
// That is a circular dead end at the worst possible moment: to restore an app you must select it, to
// select it you must have installed it, and to know what to install you must see the backup you
// cannot see. A household that has just lost its box does not know what it used to run.
//
// The toggle is a statement about FUTURE backups. Requiring it to look at a PAST one conflates two
// different questions, and that conflation is the defect.
//
// THE RULE, stated where it is implemented: the store is the source of the list. Installed-ness is a
// property OF a row, never a filter on it — it changes what restoring implies, not whether the row
// exists.
// offboxMarkerTag is on EVERY off-site snapshot (`restic backup --tag felhom-offbox --tag <stack>`),
// so it appears in the tag set beside the real app names. It is a marker, not an app, and listing it
// would offer the customer a restore of something that does not exist.
const offboxMarkerTag = "felhom-offbox"
// offsiteStoreState is what we know about the repository itself, kept separate from the rows so that
// "we could not read it" can never be rendered as "there is nothing in it".
//
// This is R-225's rule one screen over: an unknown is not a zero. The remote-backup card already
// learned to say „még nem tudjuk" rather than 0; a restore list that renders a read failure as an
// empty list makes the same false claim about something more consequential.
type offsiteStoreState string
const (
// offsiteStoreKnown — the repository was read. The rows are the truth.
offsiteStoreKnown offsiteStoreState = "known"
// offsiteStoreUnreadable — the repository could not be read. Rows may still list installed apps,
// but the page MUST say the store's contents are unknown.
offsiteStoreUnreadable offsiteStoreState = "unreadable"
// offsiteStoreNoTarget — no off-site target is configured yet. Distinguished from unreadable
// because it resolves by itself once the tier is (re-)applied, which is the pristine rebuilt
// shape (R-236).
offsiteStoreNoTarget offsiteStoreState = "no-target"
)
// OffsiteRestoreRow is one restorable thing, from the union of "in the store" and "installed here".
type OffsiteRestoreRow struct {
App string
DisplayName string
Slug string
// InStore — a snapshot in the repository carries this app's tag. This is what makes the row
// restorable at all.
InStore bool
// Installed — the app is deployed on this box right now. NOT a filter: it decides what restoring
// implies (restore in place vs. reinstall first), which the page states rather than hiding.
Installed bool
// Enabled — the app is toggled on for FUTURE off-site backups. Carried for display only; it must
// never gate a restore.
Enabled bool
// StoreUnknown — the repository could not be read, so InStore is not a claim about anything. The
// row keeps its action: refusing to offer a restore because we could not look would be the SAME
// false claim as rendering the read failure as an empty list, pointed the other way. The customer
// is told we could not read it; the attempt then fails honestly rather than being pre-empted.
StoreUnknown bool
LatestAt time.Time
SizeBytes int64
}
// Restorable reports whether a restore may be OFFERED for this row: the store holds it, or we could
// not read the store and must not pretend that means "no".
func (r OffsiteRestoreRow) Restorable() bool { return r.InStore || r.StoreUnknown }
// buildOffsiteRestoreRows merges the repository's contents with the installed set.
//
// Pure on purpose: every §7 edge case (a snapshot with no app, an app with no snapshot, an unreadable
// store, the marker tags) is a row in one table test rather than a live-repository fixture.
//
// invErr is classified, not swallowed: a not-yet-configured target and a failed read are different
// answers to the customer and the page says which.
func buildOffsiteRestoreRows(inv backup.OffsiteInventory, invErr error, installed []OffboxAppRow) ([]OffsiteRestoreRow, offsiteStoreState) {
state := offsiteStoreKnown
if invErr != nil {
state = offsiteStoreUnreadable
if backup.ErrNoOffsiteTarget(invErr) {
state = offsiteStoreNoTarget
}
}
byApp := map[string]*OffsiteRestoreRow{}
// The store first — it is the source of the list.
if invErr == nil {
for _, a := range inv.Apps {
if a.App == offboxMarkerTag || a.App == backup.SharesPseudoStack {
// The marker is not an app; shares have their own entry with their own restore path
// and no per-app wizard, so a synthetic row here would be a button that cannot work.
continue
}
byApp[a.App] = &OffsiteRestoreRow{
App: a.App, DisplayName: a.App, InStore: true,
LatestAt: a.LatestAt, SizeBytes: a.SizeBytes,
}
}
}
// Then the installed set — it supplies display names and slugs, and contributes rows of its own so
// that "installed, but nothing to restore" is SHOWN rather than silently absent.
for _, ia := range installed {
row, ok := byApp[ia.Name]
if !ok {
row = &OffsiteRestoreRow{App: ia.Name}
byApp[ia.Name] = row
}
row.Installed = true
row.Enabled = ia.Enabled
if ia.DisplayName != "" {
row.DisplayName = ia.DisplayName
}
row.Slug = ia.Slug
}
out := make([]OffsiteRestoreRow, 0, len(byApp))
for _, r := range byApp {
r.StoreUnknown = state != offsiteStoreKnown
out = append(out, *r)
}
// Restorable rows first (that is what the page is for), then alphabetically — a stable order, so
// the list does not reshuffle between reloads.
sort.Slice(out, func(i, j int) bool {
if out[i].InStore != out[j].InStore {
return out[i].InStore
}
return out[i].App < out[j].App
})
return out, state
}
// offsiteRestoreRows reads the repository and merges it with the installed set. One `restic
// snapshots --json` behind the manager's own probe timeout; a read failure is CLASSIFIED and
// returned, never swallowed into an empty list.
func (s *Server) offsiteRestoreRows(ctx context.Context) ([]OffsiteRestoreRow, offsiteStoreState) {
installed := s.buildOffboxApps()
if s.backupMgr == nil {
return buildOffsiteRestoreRows(backup.OffsiteInventory{}, errNoBackupManager, installed)
}
inv, err := s.backupMgr.OffsiteInventoryList(ctx)
if err != nil && !backup.ErrNoOffsiteTarget(err) {
// Loud on purpose: an unreadable store is the case that used to render as "empty", and the
// customer-facing wording depends on this being distinguishable in the log too.
s.logger.Printf("[WARN] [web] offsite restore list: repository unreadable — listing it as UNKNOWN, not empty: %v", err)
}
return buildOffsiteRestoreRows(inv, err, installed)
}
// errNoBackupManager stands in for "this box cannot answer" so the page says unknown rather than
// empty when the manager is absent (tests, and the brief window before wiring).
var errNoBackupManager = errors.New("backup manager unavailable")
// resolveOffsiteRestoreApp finds the wizard's app among the rows.
//
// It deliberately does NOT require the future-backup toggle — that gate is what R-237 is about. It
// requires the row to be RESTORABLE, because a wizard for an app with no snapshot would be a page of
// controls with nothing behind them.
func resolveOffsiteRestoreApp(rows []OffsiteRestoreRow, name string) *OffsiteRestoreRow {
for i := range rows {
if rows[i].App == name && rows[i].Restorable() {
cp := rows[i]
return &cp
}
}
return nil
}
@@ -0,0 +1,173 @@
package web
import (
"errors"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
)
// R-237 — the truth table for "what may this customer restore". Every §7 edge case is a row here
// rather than a live-repository fixture, because the case that matters most (a REBUILT box: nothing
// installed, nothing toggled, snapshots present) is precisely the one that is hard to stand up live
// and was therefore never tested before it shipped broken.
func rowFor(rows []OffsiteRestoreRow, app string) *OffsiteRestoreRow {
for i := range rows {
if rows[i].App == app {
return &rows[i]
}
}
return nil
}
func TestOffsiteRestoreRows_TruthTable(t *testing.T) {
now := time.Now()
inv := backup.OffsiteInventory{Apps: []backup.OffsiteInventoryApp{
{App: "calibre-web", LatestAt: now, SizeBytes: 12345},
{App: "immich", LatestAt: now},
{App: offboxMarkerTag}, // the marker every snapshot carries
{App: backup.SharesPseudoStack}, // shares have their own entry, not an app wizard
}}
installed := []OffboxAppRow{
{Name: "calibre-web", DisplayName: "Calibre-Web", Slug: "cw", Enabled: false}, // toggled OFF on purpose
{Name: "bookstack", DisplayName: "BookStack", Slug: "bs", Enabled: true}, // installed, no snapshot
}
rows, state := buildOffsiteRestoreRows(inv, nil, installed)
if state != offsiteStoreKnown {
t.Fatalf("state = %q, want known", state)
}
// THE FINDING: a snapshot whose app is NOT installed is listed, and is restorable.
if r := rowFor(rows, "immich"); r == nil || !r.InStore || r.Installed || !r.Restorable() {
t.Errorf("immich: want listed+in-store+not-installed+restorable, got %+v", r)
}
// THE OTHER HALF OF THE FINDING: the future-backup toggle must not gate a past backup.
r := rowFor(rows, "calibre-web")
if r == nil || !r.Restorable() {
t.Fatalf("calibre-web must be restorable even with the offsite toggle OFF, got %+v", r)
}
if r.Enabled {
t.Errorf("fixture sanity: calibre-web should be carrying Enabled=false")
}
if r.DisplayName != "Calibre-Web" || r.Slug != "cw" {
t.Errorf("installed metadata should decorate the store row, got %+v", r)
}
// An installed app with no snapshot is SHOWN, not silently absent — and is not restorable.
if r := rowFor(rows, "bookstack"); r == nil || r.InStore || !r.Installed || r.Restorable() {
t.Errorf("bookstack: want listed+installed+not-in-store+not-restorable, got %+v", r)
}
// Neither marker tag is an app.
if rowFor(rows, offboxMarkerTag) != nil {
t.Errorf("the %q marker tag must never be offered as an app", offboxMarkerTag)
}
if rowFor(rows, backup.SharesPseudoStack) != nil {
t.Errorf("the shares pseudo-stack must never appear as an app row")
}
}
// The rebuilt box: nothing installed, nothing toggled, snapshots in the store. This is the exact
// shape measured on 2026-08-06 that produced „Nincs telepített alkalmazás" and a wizard that refused
// every app.
func TestOffsiteRestoreRows_RebuiltBox_SeesItsSnapshots(t *testing.T) {
inv := backup.OffsiteInventory{Apps: []backup.OffsiteInventoryApp{
{App: "calibre-web", LatestAt: time.Now()},
{App: offboxMarkerTag},
}}
rows, state := buildOffsiteRestoreRows(inv, nil, nil) // nil = NOTHING installed
if state != offsiteStoreKnown {
t.Fatalf("state = %q", state)
}
if len(rows) != 1 {
t.Fatalf("want exactly the one stored app, got %d: %+v", len(rows), rows)
}
if !rows[0].Restorable() || rows[0].Installed {
t.Errorf("a rebuilt box must be offered its stored app, got %+v", rows[0])
}
if resolveOffsiteRestoreApp(rows, "calibre-web") == nil {
t.Errorf("the wizard must resolve an app that is in the store but not installed")
}
}
// R-225's rule one screen over: a read failure is UNKNOWN, never empty — and it must not withhold the
// action either, because "we could not look" is not "there is nothing".
func TestOffsiteRestoreRows_UnreadableStoreIsUnknownNotEmpty(t *testing.T) {
installed := []OffboxAppRow{{Name: "calibre-web", DisplayName: "Calibre-Web"}}
rows, state := buildOffsiteRestoreRows(backup.OffsiteInventory{}, errors.New("sftp: connection refused"), installed)
if state != offsiteStoreUnreadable {
t.Fatalf("state = %q, want unreadable", state)
}
r := rowFor(rows, "calibre-web")
if r == nil || r.InStore {
t.Fatalf("unreadable store must not claim the app IS in it, got %+v", r)
}
if !r.StoreUnknown || !r.Restorable() {
t.Errorf("unreadable store must keep the action offered and flagged unknown, got %+v", r)
}
if resolveOffsiteRestoreApp(rows, "calibre-web") == nil {
t.Errorf("the wizard must not pre-emptively refuse when the store could not be read")
}
}
// The pristine rebuilt shape (R-236): no target yet. Distinguished from a read failure because it
// resolves by itself, and the page says so.
func TestOffsiteRestoreRows_NoTargetIsItsOwnState(t *testing.T) {
_, state := buildOffsiteRestoreRows(backup.OffsiteInventory{}, backup.ErrNoOffsiteTargetSentinel(), nil)
if state != offsiteStoreNoTarget {
t.Fatalf("state = %q, want no-target", state)
}
}
// A genuinely empty repository is empty — the one case where "nothing to restore" is true.
func TestOffsiteRestoreRows_EmptyStoreIsEmpty(t *testing.T) {
rows, state := buildOffsiteRestoreRows(backup.OffsiteInventory{Empty: true}, nil, nil)
if state != offsiteStoreKnown || len(rows) != 0 {
t.Fatalf("want known+empty, got state=%q rows=%+v", state, rows)
}
}
// The rendered page, not just the builder: a rebuilt box must SEE its restore entry. A helper-level
// assertion could not observe this — the defect was in what the template iterated.
func TestOffsiteRestoreList_RenderedForRebuiltBox(t *testing.T) {
data := splitTestData()
data["OffboxApps"] = []OffboxAppRow{} // nothing installed
data["OffboxToggledCount"] = 0 // nothing toggled
data["OffsiteRestoreRows"] = []OffsiteRestoreRow{
{App: "calibre-web", DisplayName: "Calibre-Web", InStore: true, Installed: false},
}
html := renderBackupPage(t, "backups_restore", data)
if !strings.Contains(html, `href="/backups/restore/app?name=calibre-web"`) {
t.Errorf("a rebuilt box must be offered its stored app; the page did not render the entry")
}
if !strings.Contains(html, "Nincs telepítve — a visszaállítás előbb újratelepíti.") {
t.Errorf("the not-installed consequence must be stated, not hidden")
}
if strings.Contains(html, "Nincs távoli mentésre jelölt alkalmazás") {
t.Errorf("the old toggle-keyed dead end is still rendered")
}
}
func TestOffsiteRestoreList_RenderedStates(t *testing.T) {
for _, tc := range []struct{ state, want string }{
{"unreadable", "nem tudjuk, mi van benne"},
{"no-target", "kapcsolódási adatai még nem érkeztek meg"},
} {
data := splitTestData()
data["OffsiteRestoreRows"] = []OffsiteRestoreRow{}
data["OffsiteStoreState"] = tc.state
html := renderBackupPage(t, "backups_restore", data)
if !strings.Contains(html, tc.want) {
t.Errorf("state %q: page must say %q", tc.state, tc.want)
}
if strings.Contains(html, "A távoli tároló üres") {
t.Errorf("state %q must NOT be rendered as an empty store", tc.state)
}
}
}
+12 -19
View File
@@ -160,23 +160,6 @@ func deriveWizardStep(in restoreWizardInput) restoreWizardView {
}
}
// resolveWizardApp finds the wizard's app in the offsite-toggled set — the same gating the list page
// applies. Pure, so the two refusal rows (unknown app, app present but NOT toggled for offsite) are
// table-testable without a live backup manager.
//
// An app that is not toggled has no offsite snapshot to restore FROM, so its wizard would be a page
// of controls that cannot work. Both refusals return nil and the caller redirects — a customer-visible
// URL that survives a bookmark, an app rename or a toggle being switched off must never 500.
func resolveWizardApp(rows []OffboxAppRow, name string) *OffboxAppRow {
for _, a := range rows {
if a.Name == name && a.Enabled {
cp := a
return &cp
}
}
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
@@ -216,9 +199,19 @@ func (s *Server) backupsRestoreWizardHandler(w http.ResponseWriter, r *http.Requ
return
}
row := resolveWizardApp(s.buildOffboxApps(), app)
// R-237: the gate is "is it in the store", NOT "is it toggled on for future backups". The old
// resolver required the toggle, which locked a rebuilt box out of its own snapshots — measured
// live on the R-201 re-walk. A refusal here still never 500s.
rows, storeState := s.offsiteRestoreRows(r.Context())
row := resolveOffsiteRestoreApp(rows, app)
if row == nil {
offboxRedirectTo(w, r, "/backups/restore", "Ez az alkalmazás nincs távoli mentésre kijelölve.", true)
msg := "Ehhez az alkalmazáshoz nincs mentés a távoli tárolóban."
if storeState != offsiteStoreKnown {
// Never say "there is nothing" when we could not look — R-225's rule, one screen over.
msg = "Nem tudjuk elolvasni a távoli tárolót, ezért nem tudjuk, van-e benne mentés ehhez az alkalmazáshoz."
}
s.logger.Printf("[INFO] [web] restore wizard refused for %q: not restorable (store=%s)", app, storeState)
offboxRedirectTo(w, r, "/backups/restore", msg, true)
return
}
+17 -10
View File
@@ -113,23 +113,30 @@ func TestDeriveWizardStep_Table(t *testing.T) {
// --- Group C (Scenario B error rows): refusals resolve, never 500 ----------------------------------
func TestResolveWizardApp_Refusals(t *testing.T) {
rows := []OffboxAppRow{
{Name: "immich", DisplayName: "Immich", Enabled: true},
{Name: "radarr", DisplayName: "Radarr", Enabled: false},
func TestResolveOffsiteRestoreApp_Refusals(t *testing.T) {
// R-237 replaced the toggle gate with a store gate. The row that used to be refused for being
// untoggled (radarr) now resolves when the STORE holds it — that reversal IS the fix.
rows := []OffsiteRestoreRow{
{App: "immich", DisplayName: "Immich", InStore: true, Enabled: true},
{App: "radarr", DisplayName: "Radarr", InStore: true, Enabled: false},
{App: "bookstack", DisplayName: "BookStack", Installed: true},
}
if got := resolveWizardApp(rows, "immich"); got == nil || got.DisplayName != "Immich" {
t.Fatalf("toggled app must resolve, got %+v", got)
if got := resolveOffsiteRestoreApp(rows, "immich"); got == nil || got.DisplayName != "Immich" {
t.Fatalf("a stored app must resolve, got %+v", got)
}
if got := resolveWizardApp(rows, "radarr"); got != nil {
t.Errorf("an app that is NOT toggled for offsite has no snapshot to restore from — want nil, got %+v", got)
if got := resolveOffsiteRestoreApp(rows, "radarr"); got == nil {
t.Errorf("the offsite TOGGLE must not gate a PAST backup — want resolved, got nil")
}
if got := resolveWizardApp(rows, "does-not-exist"); got != nil {
if got := resolveOffsiteRestoreApp(rows, "bookstack"); got != nil {
t.Errorf("an app with nothing in the store has no restore to offer — want nil, got %+v", got)
}
if got := resolveOffsiteRestoreApp(rows, "does-not-exist"); got != nil {
t.Errorf("unknown app must not resolve, got %+v", got)
}
if got := resolveWizardApp(nil, "immich"); got != nil {
if got := resolveOffsiteRestoreApp(nil, "immich"); got != nil {
t.Errorf("empty set must not resolve, got %+v", got)
}
}
// The customer-visible URL must redirect, not 500, when the offsite target is not configured at all.
@@ -66,22 +66,40 @@
<div class="backup-section-card">
<h3>Visszaállítás a távoli tárolóból</h3>
<p class="form-hint" style="margin:-0.25rem 0 1rem">Válaszd ki az alkalmazást, és a következő oldalon döntsd el, mit szeretnél: ellenőrzést külön mappába, csak a hiányzó fájlok visszahozását, vagy teljes visszaállítást. Egyik sem indul el kérdés nélkül.</p>
{{if .OffboxToggledCount}}
<!-- R-237: driven by what is IN THE STORE, not by what is deployed and toggled. A rebuilt box has
neither and used to be told there was nothing to restore, while its snapshots sat in the
repository the whole time. Installed-ness is a property OF a row, never a filter on it. -->
{{if eq .OffsiteStoreState "unreadable"}}
<p class="form-hint">Nem tudjuk elolvasni a távoli tárolót, ezért <strong>nem tudjuk, mi van benne</strong>. Ez nem azt jelenti, hogy üres — próbáld újra később, vagy jelezd az üzemeltetőnek.</p>
{{else if eq .OffsiteStoreState "no-target"}}
<p class="form-hint">A távoli tároló kapcsolódási adatai még nem érkeztek meg ehhez a géphez, ezért még nem tudjuk megmutatni, mi van benne. Ez magától rendeződik.</p>
{{end}}
{{if .OffsiteRestoreRows}}
<div class="app-row-list">
{{range .OffboxApps}}
{{if .Enabled}}
{{range .OffsiteRestoreRows}}
{{template "app_list_row" dict "Slug" .Slug "Name" .DisplayName}}
<!-- R-48: ONE entry per app. The five inline forms that used to live here — verify,
prepare, the revealed commit, the missing-only merge and the true reconstitution —
were separable only by layout, and two of them differed by whether the customer's
data comes back at all. They are now described intents inside the wizard. -->
<a href="/backups/restore/app?name={{.Name}}" class="btn btn-xs btn-outline">Visszaállítás…</a>
{{if .Restorable}}
{{if and .InStore (not .Installed)}}
<span class="form-hint" style="margin-right:.5rem">Nincs telepítve — a visszaállítás előbb újratelepíti.</span>
{{end}}
{{if .StoreUnknown}}
<span class="form-hint" style="margin-right:.5rem">Nem tudjuk, van-e mentése — a tárolót nem sikerült elolvasni.</span>
{{end}}
<!-- R-48: ONE entry per app. The five inline forms that used to live here — verify,
prepare, the revealed commit, the missing-only merge and the true reconstitution —
were separable only by layout, and two of them differed by whether the customer's
data comes back at all. They are now described intents inside the wizard. -->
<a href="/backups/restore/app?name={{.App}}" class="btn btn-xs btn-outline">Visszaállítás…</a>
{{else}}
<!-- Shown, not hidden: "installed but nothing in the store" is an answer. Silently
dropping the row is what made R-220's empty list unreadable, one screen over. -->
<span class="form-hint">Nincs mentése a távoli tárolóban — nincs mit visszaállítani.</span>
{{end}}
{{template "app_list_row_end"}}
{{end}}
{{end}}
</div>
{{else}}
<p class="form-hint">Nincs távoli mentésre jelölt alkalmazás — a kijelölés a <a href="/backups/remote">Távoli mentés</a> oldalon történik.</p>
{{else if eq .OffsiteStoreState "known"}}
<p class="form-hint">A távoli tároló üres — nincs mit visszaállítani.</p>
{{end}}
<!-- R-7b: the shares source. Not an app row — it has no per-app toggle and no recovery unit —