636c51e542
A customer whose machine was rebuilt had everything needed to get their data back and no way to find out: the only route was a command line. This is the screen that closes that. IT UNLOCKS, AND ONLY UNLOCKS (operator ruling). It explains, takes the recovery code, opens the repository and shows what is in there — apps, dates, sizes. It restores nothing: restore is already per-app and lives in the backups area, and a screen that unlocks and then offers to overwrite is two decisions wearing one button. ONE CORE, TWO CALLERS. RecoverInstallCore is split out of RecoverAndInstall; the CLI wrapper keeps its exit codes and printed lines byte-identical, and the handler drives the same function. Two implementations of the one operation that can permanently lose a customer's data would drift, and only one would be tested. Asserted from source on both sides by AST. THREE WAYS OUT, none a dismiss button: recover; 'most nem' (the full page stops interrupting, the backups-area entry point stays PERMANENTLY, bound to the offer and never to the postpone flag); and 'I do not want the old data' — confirmed TWICE and reaching the SHIPPED move-aside, which sets aside and never deletes. THE CODE IS HANDLED NO MORE LOOSELY THAN ON THE COMMAND LINE: POST body only, never logged, never persisted, never echoed, cleared on every path, no-store, autocomplete off. No lockout — the code is a ten-word phrase, and locking a customer out of their own data for a typo is worse than anything it prevents. TWO DEFECTS THE TESTS CAUGHT, both fixed: an UNCLAIMED (legacy-open) box would have been shown the page, because RequireAuth passes such a box through; and the inventory nil-dereferenced when no off-site target was configured, which is exactly the pristine rebuilt shape.
126 lines
5.0 KiB
Go
126 lines
5.0 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) }
|
|
|
|
// 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) }
|