R-331 (controller half): forward stats_known so the hub can tell empty from unmeasured (v0.225.0)
gates / gates (push) Successful in 12s

The hub's operator Backup card read `Snapshots 0 / Repo Size 0 MB / Integrity
Unknown` for EVERY customer, because it rendered the report's `backup` object --
whose snapshot/size/integrity fields have had NO producer since disk-tier restic
moved to the host agent (slice 8C). buildBackupReport leaves them zero
deliberately and says so. Measured on demo-hp 2026-08-30 while that night's log
said `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s), 2m14s`.

The live numbers were always in the report's `offsite` object, which the hub
already reads for its Offsite page and its fill/staleness alarms. The hub fix is
to render that -- and that made exactly ONE field mandatory that was not being
forwarded.

snapshot_count:0 means two opposite things: "holds nothing" and "never
measured". R-225 measured that confusion inside this repo (a rebuilt box
rendered 0 pillanatkep over a store really holding snapshot f3d9cd67), and
settings.OffboxTarget.StatsKnown fixed it for the controller's own UI. It was
never put on the wire, so the hub was free to make the identical mistake one
layer up -- and did. OffboxReportStatus.StatsKnown now carries it, omitempty, so
an older controller sends no key and a reader degrades to UNKNOWN, never to
EMPTY. Absence is ignorance, not emptiness.

The four dead BackupReport fields stay on the wire (historical reports in the
hub store must keep parsing) but now carry a warning naming R-331 and pointing
at Offsite. TestBackupReport_DeadFieldsStayZero fails the moment a producer
appears for one -- the prompt to update the hub card in the SAME change rather
than ship a field nothing renders.

RED-PROOF: drop `StatsKnown: t.StatsKnown` -> "a MEASURED empty repository
reported stats_known=<nil>". Tests assert the JSON the hub sees, not the Go
struct: measured-empty and never-measured must differ ON THE WIRE, which is the
entire point of the field.

Green gate clean: 28 packages, rc 0.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LB8FmJaGd2cyjvy6dbEjpM
This commit is contained in:
2026-08-30 18:38:10 +02:00
parent 45b52b6ed5
commit e5eee501b5
7 changed files with 324 additions and 180 deletions
@@ -0,0 +1,122 @@
package backup
import (
"encoding/json"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
)
// ── R-331 — the hub must be able to tell "empty repository" from "never measured" ────────────────
//
// THE DEFECT THESE PIN. The hub's operator Backup card read `Snapshots 0 · Repo Size 0 MB` for EVERY
// customer. Measured on `demo-hp` 2026-08-30: the card said 0 while the box's own settings held
// `snapshot_count: 67, repo_size_bytes: 140829678, stats_known: true` and that night's log said
// `[offbox] backup OK: 8 app(s) backed up, 67 snapshot(s)`.
//
// The card rendered the report's `backup` object, whose `snapshot_count` / `repo_size_mb` /
// `integrity_ok` fields have had NO producer since disk-tier restic moved to the host agent (slice
// 8C) — `buildBackupReport` says so in a comment and leaves them zero. The live numbers were in the
// report all along, in the `offsite` object, which the hub's own OffsiteChecker already reads.
//
// So the hub fix is to render `offsite`. That makes ONE field mandatory that was not being forwarded,
// and these tests are why: **zero is what an unread repository and a genuinely empty one both look
// like, and they are opposite news.** R-225 already measured that confusion inside the controller —
// a rebuilt box rendered „Tároló méret · 0 pillanatkép" over a store that really held snapshot
// `f3d9cd67` — and `StatsKnown` is what fixed it there. Not forwarding it left the hub free to make
// the identical mistake one layer up.
// RED-PROOF (run 2026-08-30, recorded in REPORT.md): drop `StatsKnown: t.StatsKnown` from
// OffboxReportStatus() → this fails with "OffboxReportStatus dropped StatsKnown".
func TestOffboxReportStatus_CarriesStatsKnown(t *testing.T) {
m := &Manager{settings: newTestSettings(t)}
if err := m.settings.SetOffboxTarget(&settings.OffboxTarget{
Enabled: true, Host: "nas.example", User: "u1", RepoPath: "/vol/repo",
EscrowState: "escrowed", LastStatus: "ok", LastSuccess: "2026-08-30T02:17:19Z",
SnapshotCount: 67, RepoSizeBytes: 140829678, StatsKnown: true,
}); err != nil {
t.Fatalf("seed: %v", err)
}
got := m.OffboxReportStatus()
if got == nil {
t.Fatal("OffboxReportStatus returned nil for an enabled target")
}
if !got.StatsKnown {
t.Error("OffboxReportStatus dropped StatsKnown — the hub cannot then tell an empty " +
"repository from an unmeasured one, and will render a real backup as `Snapshots 0`")
}
// The counts must ride along with it, or "known" is a claim about nothing.
if got.SnapshotCount != 67 || got.RepoSizeBytes != 140829678 {
t.Errorf("counts = %d snapshots / %d bytes, want 67 / 140829678 (demo-hp's real values)",
got.SnapshotCount, got.RepoSizeBytes)
}
}
// A repository that was MEASURED and really is empty must be distinguishable on the wire from one
// nobody has measured. This is the whole point of the field, so it is asserted on the JSON rather
// than on the struct: the hub sees bytes, not Go values.
func TestOffboxReportStatus_MeasuredEmptyIsNotUnmeasured(t *testing.T) {
encode := func(t *testing.T, tgt *settings.OffboxTarget) map[string]any {
t.Helper()
m := &Manager{settings: newTestSettings(t)}
if err := m.settings.SetOffboxTarget(tgt); err != nil {
t.Fatalf("seed: %v", err)
}
b, err := json.Marshal(m.OffboxReportStatus())
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
base := func() *settings.OffboxTarget {
return &settings.OffboxTarget{
Enabled: true, Host: "nas.example", User: "u1", RepoPath: "/vol/repo",
EscrowState: "escrowed", LastStatus: "ok",
}
}
measuredEmpty := base()
measuredEmpty.StatsKnown = true // read the repo, it really holds nothing
unmeasured := base() // never read
me, um := encode(t, measuredEmpty), encode(t, unmeasured)
if me["snapshot_count"] != float64(0) || um["snapshot_count"] != float64(0) {
t.Fatalf("both cases must carry snapshot_count 0 — that is the premise of the whole field "+
"(measured=%v unmeasured=%v)", me["snapshot_count"], um["snapshot_count"])
}
if me["stats_known"] != true {
t.Errorf("a MEASURED empty repository reported stats_known=%v — the hub will render it as "+
"unknown and the operator never learns the repository really is empty", me["stats_known"])
}
if _, present := um["stats_known"]; present {
t.Errorf("an UNMEASURED repository emitted stats_known=%v; it must be ABSENT so a hub reading "+
"it as false degrades to \"unknown\"", um["stats_known"])
}
}
// Fail-safe direction, stated as a test because a comment claiming it is a wish. A hub parsing a
// pre-v0.225.0 report sees no `stats_known` key at all, which unmarshals to false — and false MUST
// mean "cannot answer", never "the answer is zero". Absence is ignorance, not emptiness.
func TestOffboxReportStatus_AbsentStatsKnownParsesAsUnknown(t *testing.T) {
const oldControllerReport = `{"enabled":true,"escrow_state":"escrowed",` +
`"last_status":"ok","snapshot_count":0,"repo_size_bytes":0,"quota_gb":50}`
var got OffboxReportStatus
if err := json.Unmarshal([]byte(oldControllerReport), &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.StatsKnown {
t.Fatal("a pre-v0.225.0 report parsed as stats_known=true — the hub would present an " +
"unmeasured repository as a confirmed-empty one")
}
if !got.Enabled {
t.Fatal("the rest of the old report stopped parsing — adding the field broke compatibility")
}
}