Files
felhom-controller/controller/internal/web/offsite_restore_list.go
T

186 lines
8.2 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 = backup.OffboxMarkerTag
// 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
}