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 `), // 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 }