R-331 (controller half): forward stats_known so the hub can tell empty from unmeasured (v0.225.0)
gates / gates (push) Successful in 12s
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:
@@ -1438,6 +1438,21 @@ type OffboxReportStatus struct {
|
||||
SnapshotCount int `json:"snapshot_count"`
|
||||
RepoSizeBytes int64 `json:"repo_size_bytes"`
|
||||
QuotaGB int `json:"quota_gb"`
|
||||
// StatsKnown (R-331) forwards settings.OffboxTarget.StatsKnown to the hub, and it is the ONLY
|
||||
// thing that lets a hub-side display tell "this repository holds nothing" from "nobody has ever
|
||||
// measured this repository". Both are `snapshot_count: 0` on the wire and they are opposite news.
|
||||
//
|
||||
// It is carried rather than inferred for the reason R-225 recorded on the settings field itself:
|
||||
// the counts are `omitempty` ints, so absent and zero are the same bytes and the distinction
|
||||
// cannot be recovered downstream. R-225 was measured live — a rebuilt box rendered
|
||||
// „Tároló méret · 0 pillanatkép" over a store that really held snapshot `f3d9cd67` — and this
|
||||
// field is what stopped the controller's own UI doing that. Not forwarding it left the hub free
|
||||
// to make the identical mistake, which it did: the operator Backup card read `Snapshots 0` for
|
||||
// every customer.
|
||||
//
|
||||
// Absent on a pre-v0.225.0 controller, and a hub MUST degrade to "unknown" there rather than to
|
||||
// "empty" — absence means the box cannot answer, never that the answer is no.
|
||||
StatsKnown bool `json:"stats_known,omitempty"`
|
||||
}
|
||||
|
||||
// OffsiteStateNeedsCredential is the ONE declared state (v0.199.0, R-204 item 4 / R-193): this box
|
||||
@@ -1628,6 +1643,7 @@ func (m *Manager) OffboxReportStatus() *OffboxReportStatus {
|
||||
Enabled: true, EscrowState: t.EscrowState, LastRun: t.LastRun, LastStatus: t.LastStatus,
|
||||
LastSuccess: t.LastSuccess,
|
||||
SnapshotCount: t.SnapshotCount, RepoSizeBytes: t.RepoSizeBytes, QuotaGB: t.QuotaGB,
|
||||
StatsKnown: t.StatsKnown, // R-331 — without it the hub cannot tell "empty" from "unmeasured"
|
||||
AbandonPurgeRequested: t.AbandonPurgeRequested, // R-241: declared until the hub drops the package
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user