From b3c835625d02822c97728d0adb29315e4bc3216f Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 6 Oct 2026 22:03:22 +0200 Subject: [PATCH] R-330: the hub models SMART 187/188/199 in the host-report (G-1; nothing alarms on them yet) Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS --- hub/CHANGELOG.md | 1 + hub/internal/api/handler.go | 11 ++++-- hub/internal/api/r330_smart_counters_test.go | 41 ++++++++++++++++++++ 3 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 hub/internal/api/r330_smart_counters_test.go diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index 1e1cd7db..f81140c1 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -2,6 +2,7 @@ - hub (R-31): a second Save of a customer's off-site settings while the first is still provisioning is refused at once (409, „already running — wait about a minute, then reload; do not save again") instead of racing it: both used to see no sub-account and create one (for a dedicated box, a second bill). Per customer, in memory; another customer is never blocked. Tests `TestProvision_R31_*` (red-proved: `documentation/audits/night-burndown-2026-10-06/hub/R-31-red.txt`). The async save with a status card stays open. - hub: the current escrow row is kept (retained, operator-only) when the whole-guest backup key's fingerprint changes, not only when the restic password changes. A reinstall mints a new PBS key while R-241 keeps the restic password, so the old rule overwrote the only copy of the old key and every pre-reinstall whole-guest archive became unopenable. An empty fingerprint on either side is unknown, not a change. Tests `TestSaveHostEscrow_R366_*` (red-proved: `documentation/audits/night-burndown-2026-10-06/r366/`). +- hub (R-330, the wire only): the host-report's storage mirror models the three SATA SMART counters agent v0.150.0+ sends — `reported_uncorrect` (187), `command_timeout` (188), `udma_crc_errors` (199); absent = unknown (omitted), a measured 0 stays 0. Accepted and stored with the report; nothing alarms on them yet. `TestHostStorageTarget_R330_SmartCountersRoundTrip`; the G-1 wire gate convicted the agent branch without it (`documentation/audits/night-burndown-2026-10-06/r330/`). ## v0.140.0 — a failed operator mail is sent again; a Docker set is approved only after the engine showed it reports a memory kill (decision 157) (2026-10-06) diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index f6e64e05..6cbe642f 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -793,9 +793,14 @@ type hostStorageTarget struct { ReallocatedSectors *int `json:"reallocated_sectors"` PendingSectors *int `json:"pending_sectors"` OfflineUncorrectable *int `json:"offline_uncorrectable"` - CriticalWarning *int `json:"critical_warning"` - MediaErrors *int `json:"media_errors"` - PercentageUsed *int `json:"percentage_used"` + // R-330 (agent v0.150.0+): SATA 187/188/199 raw counters; absent (older agent, NVMe/USB, not + // reported) = nil = unknown. Accepted and stored with the report; nothing alarms on them yet. + ReportedUncorrect *int64 `json:"reported_uncorrect,omitempty"` + CommandTimeout *int64 `json:"command_timeout,omitempty"` + UDMACRCErrors *int64 `json:"udma_crc_errors,omitempty"` + CriticalWarning *int `json:"critical_warning"` + MediaErrors *int `json:"media_errors"` + PercentageUsed *int `json:"percentage_used"` } `json:"smart"` } diff --git a/hub/internal/api/r330_smart_counters_test.go b/hub/internal/api/r330_smart_counters_test.go new file mode 100644 index 00000000..0ee49a3f --- /dev/null +++ b/hub/internal/api/r330_smart_counters_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" +) + +// R-330 (disk health Phase 2, the wire only). Agent v0.150.0+ sends SMART 187/188/199 in +// storage_targets[].smart; the hub's mirror must model them (G-1, wire_contract_gate.py) and keep an +// absent counter absent (unknown), never 0. Nothing on the hub alarms on them yet. +// +// COMPANION RED-PROOF (observed): with the agent branch emitting the tags and the hub mirror lacking +// them, `python3 scripts/wire_contract_gate.py` convicted all three +// (`documentation/audits/night-burndown-2026-10-06/r330/red-hub-wire-gate.txt`); removing the +// `ReportedUncorrect` field makes this test fail with "187 must round-trip …". Restored. +func TestHostStorageTarget_R330_SmartCountersRoundTrip(t *testing.T) { + const withCounters = `{"id":"hdd","smart":{"health":"PASSED","pending_sectors":352, + "reported_uncorrect":1001,"command_timeout":0,"udma_crc_errors":0}}` + var st hostStorageTarget + if err := json.Unmarshal([]byte(withCounters), &st); err != nil { + t.Fatal(err) + } + if st.Smart.ReportedUncorrect == nil || *st.Smart.ReportedUncorrect != 1001 { + t.Fatalf("187 must round-trip through the hub mirror (raw 1001); got %v", st.Smart.ReportedUncorrect) + } + if st.Smart.CommandTimeout == nil || *st.Smart.CommandTimeout != 0 || st.Smart.UDMACRCErrors == nil { + t.Fatalf("a measured 0 must stay a measured 0; got %v %v", st.Smart.CommandTimeout, st.Smart.UDMACRCErrors) + } + + var old hostStorageTarget + if err := json.Unmarshal([]byte(`{"id":"hdd","smart":{"health":"PASSED"}}`), &old); err != nil { + t.Fatal(err) + } + b, _ := json.Marshal(old) + for _, k := range []string{"reported_uncorrect", "command_timeout", "udma_crc_errors"} { + if strings.Contains(string(b), `"`+k+`"`) { + t.Fatalf("an older agent's report must not grow a %q key (unknown is absent, not 0): %s", k, b) + } + } +}