Files
felhom-controller/controller/internal/backup/offbox_inventory.go
T
admin 53e9bf0224
gates / gates (push) Successful in 26s
v0.204.0 — the restore list is keyed on the store (R-237); the size gate stops refusing in silence (R-238)
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.
2026-08-06 16:44:05 +02:00

132 lines
5.4 KiB
Go

package backup
import (
"context"
"encoding/json"
"errors"
"sort"
"time"
)
// R-193 Part 3 — WHAT IS IN THERE. After a successful unlock the customer is shown the contents of the
// repository they just opened: which apps, from when, how big.
//
// READ-ONLY, AND THAT IS THE POINT. This restores nothing, puts nothing back, and compares nothing
// against live data. Unlocking and restoring are separate (operator ruling, 2026-08-05): restore is
// already per-app and already lives in the backups area, and a screen that unlocks and then offers to
// overwrite is two decisions wearing one button.
//
// WHY A LISTING AT ALL, rather than a success message: "unlocked" with nothing shown is
// indistinguishable from having unlocked an EMPTY store, and the customer has no way to tell whether
// what came back is the right thing. Seeing their own app names and dates is how they know.
// errNoOffsiteTarget is returned when the repository cannot even be addressed — no off-site target is
// configured on this box yet. Distinguished from a read failure because the remedy differs: this one
// resolves by itself once the tier is re-applied.
var errNoOffsiteTarget = errors.New("no off-site target is configured on this box yet")
// ErrNoOffsiteTarget reports whether err is the not-yet-configured case, so a caller can say the right
// thing rather than showing a generic failure.
func ErrNoOffsiteTarget(err error) bool { return errors.Is(err, errNoOffsiteTarget) }
// ErrNoOffsiteTargetSentinel exposes the sentinel itself so other packages — and their tests — can
// construct the not-yet-configured case. Added for R-237, whose restore list must distinguish
// "no target yet" (resolves by itself) from "could not read" (does not), and must be able to pin
// both in a table test.
func ErrNoOffsiteTargetSentinel() error { return errNoOffsiteTarget }
// OffsiteInventoryApp is one app's presence in the opened repository. Non-secret throughout.
type OffsiteInventoryApp struct {
App string // the restic tag == the stack name
LatestAt time.Time // the newest snapshot's time for this app
SizeBytes int64 // restore size of that newest snapshot (0 = could not be determined)
}
// OffsiteInventory is the whole answer, including the EMPTY case stated explicitly.
type OffsiteInventory struct {
Apps []OffsiteInventoryApp
// Empty is true when the repository opened cleanly and holds no snapshots. It is a real and
// confusing outcome — a bare list there reads as a broken page — so it is named rather than
// inferred from len(Apps)==0, which is also what a failed read looks like.
Empty bool
}
// OffsiteInventoryList opens the repository and reports what is in it, grouped per app. One
// `snapshots --json` call for the whole repo, then one `stats` per app for the newest snapshot's size.
//
// A per-app size failure is NOT fatal: the app is still listed, with SizeBytes 0, because knowing an
// app is in there matters more than knowing how big it is, and dropping it would under-report the
// customer's own data.
func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, error) {
var inv OffsiteInventory
// A box can hold a recovered key and still have no off-site COORDINATES — the pristine rebuilt
// shape, before its target is re-applied. Reading the repository is impossible then, and saying so
// is the honest answer; without this guard offboxBaseArgs nil-derefs on the missing target.
if !m.OffboxConfigured() {
return inv, errNoOffsiteTarget
}
t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t)
sctx, cancel := context.WithTimeout(ctx, offboxProbeTimeout)
defer cancel()
out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...)
if err != nil {
return inv, err
}
var snaps []struct {
ShortID string `json:"short_id"`
ID string `json:"id"`
Time time.Time `json:"time"`
Tags []string `json:"tags"`
}
if uerr := json.Unmarshal(out, &snaps); uerr != nil {
return inv, uerr
}
if len(snaps) == 0 {
inv.Empty = true
return inv, nil
}
// Newest snapshot per tag. A snapshot may carry several tags; each names an app it belongs to.
newest := map[string]struct {
id string
at time.Time
}{}
for _, s := range snaps {
id := s.ShortID
if id == "" {
id = s.ID
}
for _, tag := range s.Tags {
if tag == "" {
continue
}
if cur, ok := newest[tag]; !ok || s.Time.After(cur.at) {
newest[tag] = struct {
id string
at time.Time
}{id: id, at: s.Time}
}
}
}
if len(newest) == 0 {
// Snapshots exist but carry no tags — not "empty", and saying so would be a lie. Report an
// empty app list without the Empty flag; the page renders the honest in-between wording.
return inv, nil
}
for tag, n := range newest {
app := OffsiteInventoryApp{App: tag, LatestAt: n.at}
if size, serr := m.offboxSnapshotSize(ctx, n.id); serr == nil {
app.SizeBytes = size
} else {
m.logger.Printf("[WARN] [offbox] inventory: size of %s's newest snapshot unknown: %v (listing it anyway)", tag, serr)
}
inv.Apps = append(inv.Apps, app)
}
sort.Slice(inv.Apps, func(i, j int) bool { return inv.Apps[i].App < inv.Apps[j].App })
return inv, nil
}
// HumanizeBytes exposes the shared byte formatter to the web layer so the recovery page renders sizes
// the same way every other surface does.
func HumanizeBytes(n int64) string { return humanizeBytes(n) }