53e9bf0224
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.
186 lines
8.1 KiB
Go
186 lines
8.1 KiB
Go
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
|
|
}
|