controller v0.240.0: seven defects from the any-tier proof and the first nightly rotation
gates / gates (push) Successful in 13s

R-486 (P1): removing an app with its backups KEPT keeps its Tier-2 record,
so the second-drive restore is no longer refused over an intact mirror.
R-484: postgis/pgvector/timescaledb images are Postgres (logical dumps).
R-485: the backup card sizes the recovery unit and the mirror(s).
R-480: a held update's sentence leaves the card once the hold is lifted.
R-477: the update's off-site lookup is one snapshots call, no stats.
R-478: a copy older than this install's deploy does not count.
R-474: "delete backups" deletes the unit, the mirror(s) and the prefs.

Tests and red-proofs per row; evidence in felhom.eu
documentation/audits/v0240-2026-09-13/ and nightly-2026-09-13-adventurelog/.
This commit is contained in:
2026-09-13 19:26:50 +02:00
parent 0e3d831030
commit bdcbd50b42
20 changed files with 673 additions and 82 deletions
+57 -26
View File
@@ -52,19 +52,21 @@ type OffsiteInventory struct {
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
// offsiteNewest is one app tag's newest snapshot.
type offsiteNewest struct {
id string
at time.Time
}
// offsiteNewestPerTag runs ONE `snapshots --json` and returns the newest snapshot per app tag, and
// whether the repository opened cleanly and holds no snapshots at all. Shared by the inventory page
// and the update precondition (R-477), so the two cannot disagree about what is in the repository.
func (m *Manager) offsiteNewestPerTag(ctx context.Context) (map[string]offsiteNewest, bool, error) {
// 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
return nil, false, errNoOffsiteTarget
}
t := m.settings.GetOffboxTarget()
base, env := m.offboxBaseArgs(t)
@@ -72,7 +74,7 @@ func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, e
defer cancel()
out, err := m.runner()(sctx, env, append(append([]string{}, base...), "snapshots", "--json")...)
if err != nil {
return inv, err
return nil, false, err
}
var snaps []struct {
ShortID string `json:"short_id"`
@@ -81,34 +83,63 @@ func (m *Manager) OffsiteInventoryList(ctx context.Context) (OffsiteInventory, e
Tags []string `json:"tags"`
}
if uerr := json.Unmarshal(out, &snaps); uerr != nil {
return inv, uerr
return nil, false, uerr
}
if len(snaps) == 0 {
inv.Empty = true
return inv, nil
return nil, true, 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
newest := map[string]offsiteNewest{}
for _, sn := range snaps {
id := sn.ShortID
if id == "" {
id = s.ID
id = sn.ID
}
for _, tag := range s.Tags {
for _, tag := range sn.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 cur, ok := newest[tag]; !ok || sn.Time.After(cur.at) {
newest[tag] = offsiteNewest{id: id, at: sn.Time}
}
}
}
return newest, false, nil
}
// OffsiteSnapshotTimes (R-477, v0.240.0) is the newest snapshot time per app — ONE `snapshots --json`,
// no per-app `stats`. It is what the update precondition needs. Measured on demo-hp 2026-09-13: going
// through OffsiteInventoryList instead, the update's check spent its whole 15 s bound on the size calls
// and the bound killed one for an unrelated app (`size of kimai's newest snapshot unknown: signal:
// killed`). Pinned by TestR477_TheUpdateOffsiteLookupRunsNoStats.
func (m *Manager) OffsiteSnapshotTimes(ctx context.Context) (map[string]time.Time, error) {
newest, _, err := m.offsiteNewestPerTag(ctx)
if err != nil {
return nil, err
}
out := make(map[string]time.Time, len(newest))
for tag, n := range newest {
out[tag] = n.at
}
return out, nil
}
// 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
newest, empty, err := m.offsiteNewestPerTag(ctx)
if err != nil {
return inv, err
}
if empty {
inv.Empty = true
return inv, nil
}
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.