storage SAT: fix NVMe SMART counters showing 0 for power-on hours/read/write
nvme-cli emits large 64-bit counters as JSON-quoted strings on some
versions; the disk-report text generator only handled bare numbers and
{lo,hi} objects, so power_on_hours/data_units_read/data_units_written
etc. silently parsed as 0 while the structured collector path already
handled this correctly. Unify both paths on a single exported
JSONInt64/NVMeSmartLog/NVMeIDCtrl type in collector/storage.go instead
of keeping two independent nvme-cli JSON parsers in sync.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -766,7 +766,7 @@ func parseMDAdmPlatformLicense(raw string) *string {
|
|||||||
|
|
||||||
func queryDeviceSerial(devPath string) string {
|
func queryDeviceSerial(devPath string) string {
|
||||||
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
|
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
|
||||||
var ctrl nvmeIDCtrl
|
var ctrl NVMeIDCtrl
|
||||||
if json.Unmarshal(out, &ctrl) == nil {
|
if json.Unmarshal(out, &ctrl) == nil {
|
||||||
if v := cleanDMIValue(strings.TrimSpace(ctrl.SerialNumber)); v != "" {
|
if v := cleanDMIValue(strings.TrimSpace(ctrl.SerialNumber)); v != "" {
|
||||||
return v
|
return v
|
||||||
|
|||||||
@@ -84,16 +84,19 @@ func collectStorage() []schema.HardwareStorage {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// jsonInt64 accepts both a bare JSON number and a JSON-quoted number string.
|
// JSONInt64 accepts a bare JSON number (512), a JSON-quoted number string
|
||||||
// lsblk -J emits LOG-SEC / PHY-SEC as integers on util-linux ≥ 2.37 (Debian 12)
|
// ("512" — lsblk -J on util-linux < 2.37, and nvme-cli for large 64-bit
|
||||||
// but older versions emit them as strings. This type handles both.
|
// counters that would lose precision as JS numbers), or a {"lo":n,"hi":n}
|
||||||
type jsonInt64 int64
|
// object (128-bit NVMe counters on some nvme-cli versions; hi is ignored as
|
||||||
|
// no real counter exceeds 64 bits). Shared by lsblk and nvme-cli JSON output
|
||||||
|
// across the collector and the human-readable disk report.
|
||||||
|
type JSONInt64 int64
|
||||||
|
|
||||||
func (j *jsonInt64) UnmarshalJSON(data []byte) error {
|
func (j *JSONInt64) UnmarshalJSON(data []byte) error {
|
||||||
// bare number: 512
|
// bare number: 512
|
||||||
var n int64
|
var n int64
|
||||||
if err := json.Unmarshal(data, &n); err == nil {
|
if err := json.Unmarshal(data, &n); err == nil {
|
||||||
*j = jsonInt64(n)
|
*j = JSONInt64(n)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
// quoted string: "512"
|
// quoted string: "512"
|
||||||
@@ -101,24 +104,32 @@ func (j *jsonInt64) UnmarshalJSON(data []byte) error {
|
|||||||
if err := json.Unmarshal(data, &s); err == nil {
|
if err := json.Unmarshal(data, &s); err == nil {
|
||||||
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
*j = jsonInt64(n)
|
*j = JSONInt64(n)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// {"lo":n,"hi":n} 128-bit counter object
|
||||||
|
var obj struct {
|
||||||
|
Lo int64 `json:"lo"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &obj); err == nil {
|
||||||
|
*j = JSONInt64(obj.Lo)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return nil // null or unexpected type — leave zero
|
return nil // null or unexpected type — leave zero
|
||||||
}
|
}
|
||||||
|
|
||||||
// lsblkDevice is a minimal lsblk JSON record.
|
// lsblkDevice is a minimal lsblk JSON record.
|
||||||
type lsblkDevice struct {
|
type lsblkDevice struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Size string `json:"size"`
|
Size string `json:"size"`
|
||||||
Serial string `json:"serial"`
|
Serial string `json:"serial"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
Tran string `json:"tran"`
|
Tran string `json:"tran"`
|
||||||
Hctl string `json:"hctl"`
|
Hctl string `json:"hctl"`
|
||||||
LogSec jsonInt64 `json:"log-sec"`
|
LogSec JSONInt64 `json:"log-sec"`
|
||||||
PhySec jsonInt64 `json:"phy-sec"`
|
PhySec JSONInt64 `json:"phy-sec"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type lsblkRoot struct {
|
type lsblkRoot struct {
|
||||||
@@ -423,32 +434,36 @@ func enrichWithSmartctl(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
// nvmeSmartLog is the subset of `nvme smart-log -o json` output we care about.
|
// NVMeSmartLog is the subset of `nvme smart-log -o json` output shared by the
|
||||||
// nvme-cli emits most counters as JSON strings (e.g. "power_on_hours":"49"),
|
// structured collector and the human-readable disk report. nvme-cli emits
|
||||||
// so all numeric fields use jsonInt64 which accepts both bare numbers and
|
// most counters as JSON strings (e.g. "power_on_hours":"49") or, on some
|
||||||
// quoted strings. Field names match nvme-cli JSON output, not NVMe spec prose.
|
// versions, as {"lo":n,"hi":n} objects — all numeric fields use JSONInt64,
|
||||||
type nvmeSmartLog struct {
|
// which accepts bare numbers, quoted strings, and lo/hi objects. Field names
|
||||||
CriticalWarning jsonInt64 `json:"critical_warning"`
|
// match nvme-cli JSON output, not NVMe spec prose.
|
||||||
PercentageUsed jsonInt64 `json:"percent_used"`
|
type NVMeSmartLog struct {
|
||||||
AvailableSpare jsonInt64 `json:"avail_spare"`
|
CriticalWarning JSONInt64 `json:"critical_warning"`
|
||||||
SpareThreshold jsonInt64 `json:"spare_thresh"`
|
PercentageUsed JSONInt64 `json:"percent_used"`
|
||||||
Temperature jsonInt64 `json:"temperature"`
|
AvailableSpare JSONInt64 `json:"avail_spare"`
|
||||||
PowerOnHours jsonInt64 `json:"power_on_hours"`
|
SpareThreshold JSONInt64 `json:"spare_thresh"`
|
||||||
PowerCycles jsonInt64 `json:"power_cycles"`
|
Temperature JSONInt64 `json:"temperature"`
|
||||||
UnsafeShutdowns jsonInt64 `json:"unsafe_shutdowns"`
|
PowerOnHours JSONInt64 `json:"power_on_hours"`
|
||||||
DataUnitsRead jsonInt64 `json:"data_units_read"`
|
PowerCycles JSONInt64 `json:"power_cycles"`
|
||||||
DataUnitsWritten jsonInt64 `json:"data_units_written"`
|
UnsafeShutdowns JSONInt64 `json:"unsafe_shutdowns"`
|
||||||
ControllerBusy jsonInt64 `json:"controller_busy_time"`
|
DataUnitsRead JSONInt64 `json:"data_units_read"`
|
||||||
MediaErrors jsonInt64 `json:"media_errors"`
|
DataUnitsWritten JSONInt64 `json:"data_units_written"`
|
||||||
NumErrLogEntries jsonInt64 `json:"num_err_log_entries"`
|
ControllerBusy JSONInt64 `json:"controller_busy_time"`
|
||||||
|
MediaErrors JSONInt64 `json:"media_errors"`
|
||||||
|
NumErrLogEntries JSONInt64 `json:"num_err_log_entries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// nvmeIDCtrl is the subset of `nvme id-ctrl -o json` output.
|
// NVMeIDCtrl is the subset of `nvme id-ctrl -o json` output shared by the
|
||||||
type nvmeIDCtrl struct {
|
// structured collector and the human-readable disk report.
|
||||||
ModelNumber string `json:"mn"`
|
type NVMeIDCtrl struct {
|
||||||
SerialNumber string `json:"sn"`
|
ModelNumber string `json:"mn"`
|
||||||
FirmwareRev string `json:"fr"`
|
SerialNumber string `json:"sn"`
|
||||||
TotalCapacity int64 `json:"tnvmcap"`
|
FirmwareRev string `json:"fr"`
|
||||||
|
TotalCapacity JSONInt64 `json:"tnvmcap"`
|
||||||
|
NVMCapacity JSONInt64 `json:"nvmcap"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
||||||
@@ -481,7 +496,7 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
|
|
||||||
// id-ctrl: model, serial, firmware, capacity
|
// id-ctrl: model, serial, firmware, capacity
|
||||||
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
|
if out, err := exec.Command("nvme", "id-ctrl", devPath, "-o", "json").Output(); err == nil {
|
||||||
var ctrl nvmeIDCtrl
|
var ctrl NVMeIDCtrl
|
||||||
if json.Unmarshal(out, &ctrl) == nil {
|
if json.Unmarshal(out, &ctrl) == nil {
|
||||||
if v := cleanDMIValue(strings.TrimSpace(ctrl.ModelNumber)); v != "" {
|
if v := cleanDMIValue(strings.TrimSpace(ctrl.ModelNumber)); v != "" {
|
||||||
s.Model = &v
|
s.Model = &v
|
||||||
@@ -502,7 +517,7 @@ func enrichWithNVMe(dev lsblkDevice) schema.HardwareStorage {
|
|||||||
|
|
||||||
// smart-log: wear telemetry
|
// smart-log: wear telemetry
|
||||||
if out, err := exec.Command("nvme", "smart-log", devPath, "-o", "json").Output(); err == nil {
|
if out, err := exec.Command("nvme", "smart-log", devPath, "-o", "json").Output(); err == nil {
|
||||||
var log nvmeSmartLog
|
var log NVMeSmartLog
|
||||||
if json.Unmarshal(out, &log) == nil {
|
if json.Unmarshal(out, &log) == nil {
|
||||||
if log.PowerOnHours > 0 {
|
if log.PowerOnHours > 0 {
|
||||||
v := int64(log.PowerOnHours)
|
v := int64(log.PowerOnHours)
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ func TestJsonInt64UnmarshalBothFormats(t *testing.T) {
|
|||||||
{`null`, 0},
|
{`null`, 0},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
var v jsonInt64
|
var v JSONInt64
|
||||||
if err := v.UnmarshalJSON([]byte(tc.json)); err != nil {
|
if err := v.UnmarshalJSON([]byte(tc.json)); err != nil {
|
||||||
t.Fatalf("UnmarshalJSON(%s): unexpected error %v", tc.json, err)
|
t.Fatalf("UnmarshalJSON(%s): unexpected error %v", tc.json, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
// TestNVMeSmartLogUnmarshal verifies that nvme-cli JSON output (where most
|
// TestNVMeSmartLogUnmarshal verifies that nvme-cli JSON output (where most
|
||||||
// counters are quoted strings and field names differ from NVMe spec prose)
|
// counters are quoted strings and field names differ from NVMe spec prose)
|
||||||
// is correctly parsed into nvmeSmartLog.
|
// is correctly parsed into NVMeSmartLog.
|
||||||
func TestNVMeSmartLogUnmarshal(t *testing.T) {
|
func TestNVMeSmartLogUnmarshal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ func TestNVMeSmartLogUnmarshal(t *testing.T) {
|
|||||||
"media_errors": "0",
|
"media_errors": "0",
|
||||||
"num_err_log_entries": "0"
|
"num_err_log_entries": "0"
|
||||||
}`
|
}`
|
||||||
var log nvmeSmartLog
|
var log NVMeSmartLog
|
||||||
if err := json.Unmarshal([]byte(raw), &log); err != nil {
|
if err := json.Unmarshal([]byte(raw), &log); err != nil {
|
||||||
t.Fatalf("json.Unmarshal failed: %v", err)
|
t.Fatalf("json.Unmarshal failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package platform
|
package platform
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bee/audit/internal/collector"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
@@ -39,65 +40,22 @@ func GenerateDiskReportText(index int, devPath string, outputs map[string][]byte
|
|||||||
|
|
||||||
// ── NVMe ─────────────────────────────────────────────────────────────────────
|
// ── NVMe ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type nvmeIdCtrl struct {
|
|
||||||
ModelNumber string `json:"mn"`
|
|
||||||
SerialNumber string `json:"sn"`
|
|
||||||
Firmware string `json:"fr"`
|
|
||||||
TotalCap uint64 `json:"tnvmcap"`
|
|
||||||
NVMCap uint64 `json:"nvmcap"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// nvmeU64 handles both plain JSON numbers and {"lo":n,"hi":n} objects that
|
|
||||||
// some nvme-cli versions emit for 128-bit counters.
|
|
||||||
func nvmeU64(raw json.RawMessage) uint64 {
|
|
||||||
if len(raw) == 0 {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
var n uint64
|
|
||||||
if json.Unmarshal(raw, &n) == nil {
|
|
||||||
return n
|
|
||||||
}
|
|
||||||
var obj struct {
|
|
||||||
Lo uint64 `json:"lo"`
|
|
||||||
Hi uint64 `json:"hi"`
|
|
||||||
}
|
|
||||||
if json.Unmarshal(raw, &obj) == nil {
|
|
||||||
return obj.Lo
|
|
||||||
}
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
type nvmeSmartLogRaw struct {
|
|
||||||
CriticalWarning uint64 `json:"critical_warning"`
|
|
||||||
Temperature json.RawMessage `json:"temperature"`
|
|
||||||
AvailSpare uint64 `json:"avail_spare"`
|
|
||||||
SpareThresh uint64 `json:"spare_thresh"`
|
|
||||||
PercentUsed uint64 `json:"percent_used"`
|
|
||||||
DataUnitsRead json.RawMessage `json:"data_units_read"`
|
|
||||||
DataUnitsWritten json.RawMessage `json:"data_units_written"`
|
|
||||||
PowerCycles json.RawMessage `json:"power_cycles"`
|
|
||||||
PowerOnHours json.RawMessage `json:"power_on_hours"`
|
|
||||||
UnsafeShutdowns json.RawMessage `json:"unsafe_shutdowns"`
|
|
||||||
MediaErrors json.RawMessage `json:"media_errors"`
|
|
||||||
NumErrLogEntries json.RawMessage `json:"num_err_log_entries"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
||||||
// id-ctrl
|
// id-ctrl
|
||||||
var ctrl nvmeIdCtrl
|
var ctrl collector.NVMeIDCtrl
|
||||||
if data := outputs["nvme-id-ctrl"]; len(data) > 0 {
|
if data := outputs["nvme-id-ctrl"]; len(data) > 0 {
|
||||||
_ = json.Unmarshal(data, &ctrl)
|
_ = json.Unmarshal(data, &ctrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
model := strings.TrimSpace(ctrl.ModelNumber)
|
model := strings.TrimSpace(ctrl.ModelNumber)
|
||||||
serial := strings.TrimSpace(ctrl.SerialNumber)
|
serial := strings.TrimSpace(ctrl.SerialNumber)
|
||||||
firmware := strings.TrimSpace(ctrl.Firmware)
|
firmware := strings.TrimSpace(ctrl.FirmwareRev)
|
||||||
|
|
||||||
capacityGB := ""
|
capacityGB := ""
|
||||||
if ctrl.TotalCap > 0 {
|
if ctrl.TotalCapacity > 0 {
|
||||||
capacityGB = formatCapacityGB(ctrl.TotalCap)
|
capacityGB = formatCapacityGB(uint64(ctrl.TotalCapacity))
|
||||||
} else if ctrl.NVMCap > 0 {
|
} else if ctrl.NVMCapacity > 0 {
|
||||||
capacityGB = formatCapacityGB(ctrl.NVMCap)
|
capacityGB = formatCapacityGB(uint64(ctrl.NVMCapacity))
|
||||||
}
|
}
|
||||||
|
|
||||||
writeField(b, "Model", model)
|
writeField(b, "Model", model)
|
||||||
@@ -113,39 +71,37 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
|||||||
b.WriteString("\n(no SMART data)\n")
|
b.WriteString("\n(no SMART data)\n")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var sl nvmeSmartLogRaw
|
var sl collector.NVMeSmartLog
|
||||||
if err := json.Unmarshal(data, &sl); err != nil {
|
if err := json.Unmarshal(data, &sl); err != nil {
|
||||||
fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err)
|
fmt.Fprintf(b, "\n(SMART parse error: %v)\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tempK := nvmeU64(sl.Temperature)
|
tempC := int(sl.Temperature) - 273
|
||||||
tempC := int(tempK) - 273
|
|
||||||
if tempC < 0 {
|
if tempC < 0 {
|
||||||
tempC = 0
|
tempC = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
critWarn := sl.CriticalWarning
|
|
||||||
critWarnStr := "OK"
|
critWarnStr := "OK"
|
||||||
if critWarn != 0 {
|
if sl.CriticalWarning != 0 {
|
||||||
critWarnStr = fmt.Sprintf("0x%02X", critWarn)
|
critWarnStr = fmt.Sprintf("0x%02X", sl.CriticalWarning)
|
||||||
}
|
}
|
||||||
|
|
||||||
poh := nvmeU64(sl.PowerOnHours)
|
poh := uint64(sl.PowerOnHours)
|
||||||
pc := nvmeU64(sl.PowerCycles)
|
pc := uint64(sl.PowerCycles)
|
||||||
us := nvmeU64(sl.UnsafeShutdowns)
|
us := uint64(sl.UnsafeShutdowns)
|
||||||
me := nvmeU64(sl.MediaErrors)
|
me := uint64(sl.MediaErrors)
|
||||||
nel := nvmeU64(sl.NumErrLogEntries)
|
nel := uint64(sl.NumErrLogEntries)
|
||||||
|
|
||||||
// data_units are in 1000 × 512-byte sectors = 512,000 bytes each
|
// data_units are in 1000 × 512-byte sectors = 512,000 bytes each
|
||||||
dataRead := float64(nvmeU64(sl.DataUnitsRead)) * 512000 / 1e9
|
dataRead := float64(sl.DataUnitsRead) * 512000 / 1e9
|
||||||
dataWritten := float64(nvmeU64(sl.DataUnitsWritten)) * 512000 / 1e9
|
dataWritten := float64(sl.DataUnitsWritten) * 512000 / 1e9
|
||||||
|
|
||||||
writeSectionHeader(b, "Health")
|
writeSectionHeader(b, "Health")
|
||||||
writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
|
writeField(b, "Temperature", fmt.Sprintf("%d °C", tempC))
|
||||||
writeField(b, "Critical Warning", critWarnStr)
|
writeField(b, "Critical Warning", critWarnStr)
|
||||||
writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentUsed))
|
writeField(b, "Percentage Used", fmt.Sprintf("%d %%", sl.PercentageUsed))
|
||||||
writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailSpare, sl.SpareThresh))
|
writeField(b, "Available Spare", fmt.Sprintf("%d %% (threshold: %d %%)", sl.AvailableSpare, sl.SpareThreshold))
|
||||||
|
|
||||||
writeSectionHeader(b, "Usage")
|
writeSectionHeader(b, "Usage")
|
||||||
writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh)))
|
writeField(b, "Power On Hours", fmt.Sprintf("%s h", formatUint(poh)))
|
||||||
@@ -158,14 +114,14 @@ func writeNVMeReport(b *strings.Builder, outputs map[string][]byte) {
|
|||||||
writeField(b, "Media Errors", formatUint(me))
|
writeField(b, "Media Errors", formatUint(me))
|
||||||
writeField(b, "Error Log Entries", formatUint(nel))
|
writeField(b, "Error Log Entries", formatUint(nel))
|
||||||
|
|
||||||
capacityBytes := ctrl.TotalCap
|
capacityBytes := uint64(ctrl.TotalCapacity)
|
||||||
if capacityBytes == 0 {
|
if capacityBytes == 0 {
|
||||||
capacityBytes = ctrl.NVMCap
|
capacityBytes = uint64(ctrl.NVMCapacity)
|
||||||
}
|
}
|
||||||
writeResourceSection(b, resourceInfo{
|
writeResourceSection(b, resourceInfo{
|
||||||
powerOnHours: poh,
|
powerOnHours: poh,
|
||||||
writtenBytes: uint64(nvmeU64(sl.DataUnitsWritten)) * 512000,
|
writtenBytes: uint64(sl.DataUnitsWritten) * 512000,
|
||||||
readBytes: uint64(nvmeU64(sl.DataUnitsRead)) * 512000,
|
readBytes: uint64(sl.DataUnitsRead) * 512000,
|
||||||
capacityBytes: capacityBytes,
|
capacityBytes: capacityBytes,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user