mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-21 22:02:34 +00:00
refactor(agent): simplify SMART collection with per-device smartctl attempts (#2620)
Rebuild the SMART device list on every refresh instead of keeping per-device type and parser state across rescans. Each device gets a short ordered list of smartctl queries, and the three parsers are merged into one that rejects output without health data so the next query runs. - scsi devices try auto-detect, then -d sat (#2295) - NVMe controllers fall back to their n1 namespace - disks in standby reuse their last reading; a disk not yet collected is woken once per agent run - the last good reading survives temporary read failures - failed or empty rescans keep the previous device list - a missing SMART status is UNKNOWN instead of FAILED - the reported disk type is the type that worked (e.g. sat) - disks seen through two paths are reported once --------- Co-authored-by: hank <hank@henrygd.me>
This commit is contained in:
+38
-68
@@ -6,7 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/henrygd/beszel/agent/utils"
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
@@ -25,99 +24,70 @@ type emmcHealth struct {
|
||||
lifeB uint8
|
||||
}
|
||||
|
||||
func scanEmmcDevices() []*DeviceInfo {
|
||||
// scanEmmcDevices returns the /dev paths of eMMC devices that expose health
|
||||
// fields in sysfs.
|
||||
func scanEmmcDevices() []string {
|
||||
blockDir := filepath.Join(emmcSysfsRoot, "class", "block")
|
||||
entries, err := os.ReadDir(blockDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices := make([]*DeviceInfo, 0, 2)
|
||||
var devices []string
|
||||
for _, ent := range entries {
|
||||
name := ent.Name()
|
||||
if !isEmmcBlockName(name) {
|
||||
continue
|
||||
}
|
||||
|
||||
deviceDir := filepath.Join(blockDir, name, "device")
|
||||
if !hasEmmcHealthFiles(deviceDir) {
|
||||
if !hasEmmcHealthFiles(filepath.Join(blockDir, name, "device")) {
|
||||
continue
|
||||
}
|
||||
|
||||
devPath := filepath.Join("/dev", name)
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: devPath,
|
||||
Type: "emmc",
|
||||
InfoName: devPath + " [eMMC]",
|
||||
Protocol: "MMC",
|
||||
})
|
||||
devices = append(devices, filepath.Join("/dev", name))
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
if deviceInfo == nil || deviceInfo.Name == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
base := filepath.Base(deviceInfo.Name)
|
||||
if !isEmmcBlockName(base) && !strings.EqualFold(deviceInfo.Type, "emmc") && !strings.EqualFold(deviceInfo.Type, "mmc") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// readEmmcSmartData reads eMMC wear / EOL indicators from sysfs. It returns the
|
||||
// data map key (serial, or the device path when there is none).
|
||||
func readEmmcSmartData(path string) (string, *smart.SmartData, bool) {
|
||||
base := filepath.Base(path)
|
||||
health, ok := readEmmcHealth(base)
|
||||
if !ok {
|
||||
return false, nil
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
// Normalize the device type to keep pruning logic stable across refreshes.
|
||||
deviceInfo.Type = "emmc"
|
||||
|
||||
devPath := filepath.Join("/dev", base)
|
||||
key := health.serial
|
||||
if key == "" {
|
||||
key = filepath.Join("/dev", base)
|
||||
key = devPath
|
||||
}
|
||||
|
||||
status := emmcSmartStatus(health.preEOL)
|
||||
|
||||
attrs := []*smart.SmartAttribute{
|
||||
{
|
||||
Name: "PreEOLInfo",
|
||||
RawValue: uint64(health.preEOL),
|
||||
RawString: emmcPreEOLString(health.preEOL),
|
||||
return key, &smart.SmartData{
|
||||
ModelName: health.model,
|
||||
SerialNumber: health.serial,
|
||||
FirmwareVersion: health.revision,
|
||||
Capacity: health.capacity,
|
||||
SmartStatus: emmcSmartStatus(health.preEOL),
|
||||
DiskName: devPath,
|
||||
DiskType: "emmc",
|
||||
Attributes: []*smart.SmartAttribute{
|
||||
{
|
||||
Name: "PreEOLInfo",
|
||||
RawValue: uint64(health.preEOL),
|
||||
RawString: emmcPreEOLString(health.preEOL),
|
||||
},
|
||||
{
|
||||
Name: "DeviceLifeTimeEstA",
|
||||
RawValue: uint64(health.lifeA),
|
||||
RawString: emmcLifeTimeString(health.lifeA),
|
||||
},
|
||||
{
|
||||
Name: "DeviceLifeTimeEstB",
|
||||
RawValue: uint64(health.lifeB),
|
||||
RawString: emmcLifeTimeString(health.lifeB),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "DeviceLifeTimeEstA",
|
||||
RawValue: uint64(health.lifeA),
|
||||
RawString: emmcLifeTimeString(health.lifeA),
|
||||
},
|
||||
{
|
||||
Name: "DeviceLifeTimeEstB",
|
||||
RawValue: uint64(health.lifeB),
|
||||
RawString: emmcLifeTimeString(health.lifeB),
|
||||
},
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
defer sm.Unlock()
|
||||
|
||||
if _, exists := sm.SmartDataMap[key]; !exists {
|
||||
sm.SmartDataMap[key] = &smart.SmartData{}
|
||||
}
|
||||
|
||||
data := sm.SmartDataMap[key]
|
||||
data.ModelName = health.model
|
||||
data.SerialNumber = health.serial
|
||||
data.FirmwareVersion = health.revision
|
||||
data.Capacity = health.capacity
|
||||
data.Temperature = 0
|
||||
data.SmartStatus = status
|
||||
data.DiskName = filepath.Join("/dev", base)
|
||||
data.DiskType = "emmc"
|
||||
data.Attributes = attrs
|
||||
|
||||
return true, nil
|
||||
}, true
|
||||
}
|
||||
|
||||
func readEmmcHealth(blockName string) (emmcHealth, bool) {
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
func TestEmmcMockSysfsScanAndCollect(t *testing.T) {
|
||||
@@ -45,26 +43,19 @@ func TestEmmcMockSysfsScanAndCollect(t *testing.T) {
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("scanEmmcDevices() = %d devices, want 1", len(devs))
|
||||
}
|
||||
if devs[0].Name != "/dev/mmcblk0" || devs[0].Type != "emmc" {
|
||||
t.Fatalf("scanEmmcDevices()[0] = %+v, want Name=/dev/mmcblk0 Type=emmc", devs[0])
|
||||
if devs[0] != "/dev/mmcblk0" {
|
||||
t.Fatalf("scanEmmcDevices()[0] = %q, want /dev/mmcblk0", devs[0])
|
||||
}
|
||||
|
||||
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
|
||||
ok, err := sm.collectEmmcHealth(devs[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("collectEmmcHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
|
||||
// sysfs targets are read directly instead of through smartctl.
|
||||
result, err := (&SmartManager{}).collectTarget(smartTarget{name: devs[0], dtype: "emmc"})
|
||||
if err != nil {
|
||||
t.Fatalf("collectTarget() err = %v", err)
|
||||
}
|
||||
if len(sm.SmartDataMap) != 1 {
|
||||
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
|
||||
}
|
||||
var got *smart.SmartData
|
||||
for _, v := range sm.SmartDataMap {
|
||||
got = v
|
||||
break
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("SmartDataMap value nil")
|
||||
if result.key != "01234567" {
|
||||
t.Fatalf("key = %q, want 01234567", result.key)
|
||||
}
|
||||
got := result.data
|
||||
if got.DiskType != "emmc" || got.DiskName != "/dev/mmcblk0" {
|
||||
t.Fatalf("disk fields = (type=%q name=%q), want (emmc,/dev/mmcblk0)", got.DiskType, got.DiskName)
|
||||
}
|
||||
|
||||
+5
-4
@@ -2,13 +2,14 @@
|
||||
|
||||
package agent
|
||||
|
||||
import "github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
// Non-Linux builds: eMMC health via sysfs is not available.
|
||||
|
||||
func scanEmmcDevices() []*DeviceInfo {
|
||||
func scanEmmcDevices() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectEmmcHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
return false, nil
|
||||
func readEmmcSmartData(path string) (string, *smart.SmartData, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
|
||||
+20
-47
@@ -30,57 +30,37 @@ type mdraidHealth struct {
|
||||
capacity uint64
|
||||
}
|
||||
|
||||
// scanMdraidDevices discovers Linux md arrays exposed in sysfs.
|
||||
func scanMdraidDevices() []*DeviceInfo {
|
||||
// scanMdraidDevices returns the /dev paths of Linux md arrays exposed in sysfs.
|
||||
func scanMdraidDevices() []string {
|
||||
blockDir := filepath.Join(mdraidSysfsRoot, "block")
|
||||
entries, err := os.ReadDir(blockDir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
devices := make([]*DeviceInfo, 0, 2)
|
||||
var devices []string
|
||||
for _, ent := range entries {
|
||||
name := ent.Name()
|
||||
if !isMdraidBlockName(name) {
|
||||
continue
|
||||
}
|
||||
mdDir := filepath.Join(blockDir, name, "md")
|
||||
if !utils.FileExists(filepath.Join(mdDir, "array_state")) {
|
||||
if !utils.FileExists(filepath.Join(blockDir, name, "md", "array_state")) {
|
||||
continue
|
||||
}
|
||||
|
||||
devPath := filepath.Join("/dev", name)
|
||||
devices = append(devices, &DeviceInfo{
|
||||
Name: devPath,
|
||||
Type: "mdraid",
|
||||
InfoName: devPath + " [mdraid]",
|
||||
Protocol: "MD",
|
||||
})
|
||||
devices = append(devices, filepath.Join("/dev", name))
|
||||
}
|
||||
|
||||
return devices
|
||||
}
|
||||
|
||||
// collectMdraidHealth reads mdraid health and stores it in SmartDataMap.
|
||||
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
if deviceInfo == nil || deviceInfo.Name == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
base := filepath.Base(deviceInfo.Name)
|
||||
if !isMdraidBlockName(base) && !strings.EqualFold(deviceInfo.Type, "mdraid") {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// readMdraidSmartData reads mdraid health from sysfs. It returns the data map
|
||||
// key ("mdraid:<name>").
|
||||
func readMdraidSmartData(path string) (string, *smart.SmartData, bool) {
|
||||
base := filepath.Base(path)
|
||||
health, ok := readMdraidHealth(base)
|
||||
if !ok {
|
||||
return false, nil
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
deviceInfo.Type = "mdraid"
|
||||
key := fmt.Sprintf("mdraid:%s", base)
|
||||
status := mdraidSmartStatus(health)
|
||||
|
||||
attrs := make([]*smart.SmartAttribute, 0, 10)
|
||||
if health.arrayState != "" {
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "ArrayState", RawString: health.arrayState})
|
||||
@@ -110,25 +90,18 @@ func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error
|
||||
attrs = append(attrs, &smart.SmartAttribute{Name: "MismatchCount", RawValue: health.mismatchCnt})
|
||||
}
|
||||
|
||||
sm.Lock()
|
||||
defer sm.Unlock()
|
||||
|
||||
if _, exists := sm.SmartDataMap[key]; !exists {
|
||||
sm.SmartDataMap[key] = &smart.SmartData{}
|
||||
}
|
||||
|
||||
data := sm.SmartDataMap[key]
|
||||
data.ModelName = "Linux MD RAID"
|
||||
model := "Linux MD RAID"
|
||||
if health.level != "" {
|
||||
data.ModelName = "Linux MD RAID (" + health.level + ")"
|
||||
model = "Linux MD RAID (" + health.level + ")"
|
||||
}
|
||||
data.Capacity = health.capacity
|
||||
data.SmartStatus = status
|
||||
data.DiskName = filepath.Join("/dev", base)
|
||||
data.DiskType = "mdraid"
|
||||
data.Attributes = attrs
|
||||
|
||||
return true, nil
|
||||
return fmt.Sprintf("mdraid:%s", base), &smart.SmartData{
|
||||
ModelName: model,
|
||||
Capacity: health.capacity,
|
||||
SmartStatus: mdraidSmartStatus(health),
|
||||
DiskName: filepath.Join("/dev", base),
|
||||
DiskType: "mdraid",
|
||||
Attributes: attrs,
|
||||
}, true
|
||||
}
|
||||
|
||||
// readMdraidHealth reads md array health fields from sysfs.
|
||||
|
||||
@@ -6,8 +6,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
||||
@@ -56,26 +54,19 @@ func TestMdraidMockSysfsScanAndCollect(t *testing.T) {
|
||||
if len(devs) != 1 {
|
||||
t.Fatalf("scanMdraidDevices() = %d devices, want 1", len(devs))
|
||||
}
|
||||
if devs[0].Name != "/dev/md0" || devs[0].Type != "mdraid" {
|
||||
t.Fatalf("scanMdraidDevices()[0] = %+v, want Name=/dev/md0 Type=mdraid", devs[0])
|
||||
if devs[0] != "/dev/md0" {
|
||||
t.Fatalf("scanMdraidDevices()[0] = %q, want /dev/md0", devs[0])
|
||||
}
|
||||
|
||||
sm := &SmartManager{SmartDataMap: map[string]*smart.SmartData{}}
|
||||
ok, err := sm.collectMdraidHealth(devs[0])
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("collectMdraidHealth() = (ok=%v, err=%v), want (true,nil)", ok, err)
|
||||
// sysfs targets are read directly instead of through smartctl.
|
||||
result, err := (&SmartManager{}).collectTarget(smartTarget{name: devs[0], dtype: "mdraid"})
|
||||
if err != nil {
|
||||
t.Fatalf("collectTarget() err = %v", err)
|
||||
}
|
||||
if len(sm.SmartDataMap) != 1 {
|
||||
t.Fatalf("SmartDataMap len=%d, want 1", len(sm.SmartDataMap))
|
||||
}
|
||||
var got *smart.SmartData
|
||||
for _, v := range sm.SmartDataMap {
|
||||
got = v
|
||||
break
|
||||
}
|
||||
if got == nil {
|
||||
t.Fatalf("SmartDataMap value nil")
|
||||
if result.key != "mdraid:md0" {
|
||||
t.Fatalf("key = %q, want mdraid:md0", result.key)
|
||||
}
|
||||
got := result.data
|
||||
if got.DiskType != "mdraid" || got.DiskName != "/dev/md0" {
|
||||
t.Fatalf("disk fields = (type=%q name=%q), want (mdraid,/dev/md0)", got.DiskType, got.DiskName)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
package agent
|
||||
|
||||
func scanMdraidDevices() []*DeviceInfo {
|
||||
import "github.com/henrygd/beszel/internal/entities/smart"
|
||||
|
||||
func scanMdraidDevices() []string {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (sm *SmartManager) collectMdraidHealth(deviceInfo *DeviceInfo) (bool, error) {
|
||||
return false, nil
|
||||
func readMdraidSmartData(path string) (string, *smart.SmartData, bool) {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
+393
-1051
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,333 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/henrygd/beszel/internal/entities/smart"
|
||||
)
|
||||
|
||||
var errNoHealthData = errors.New("no SMART health data in smartctl output")
|
||||
|
||||
// parseSmartctl turns `smartctl -a --json` output into SmartData and returns
|
||||
// the protocol it was parsed as ("nvme", "scsi" or "ata"). dtype is the -d
|
||||
// value used for the query; when set it is reported as the disk type.
|
||||
//
|
||||
// The protocol comes from smartctl's device.protocol, falling back to the
|
||||
// sections present. Output that identifies a disk but holds no health data for
|
||||
// its protocol is rejected, so the next smartctl attempt can run instead of a
|
||||
// record of zeros being stored (#2295, #1504).
|
||||
func (sm *SmartManager) parseSmartctl(output []byte, dtype string) (*smart.SmartData, string, error) {
|
||||
data, err := decodeSmartctlOutput(output)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
protocol := smartProtocol(data)
|
||||
switch protocol {
|
||||
case "nvme":
|
||||
log := data.NVMeSmartHealthInformationLog
|
||||
if log == nil {
|
||||
return nil, protocol, errNoHealthData
|
||||
}
|
||||
smartData := newSmartData(data, dtype, data.ModelName, data.FirmwareVersion, log.Temperature)
|
||||
if smartData.Capacity == 0 {
|
||||
smartData.Capacity = data.NVMeTotalCapacity
|
||||
}
|
||||
if smartData.Capacity == 0 && (runtime.GOOS == "darwin" || sm.darwinNvmeProvider != nil) {
|
||||
smartData.Capacity = sm.lookupDarwinNvmeCapacity(data.SerialNumber)
|
||||
}
|
||||
smartData.Attributes = nvmeAttributes(log, smartData.Temperature)
|
||||
return smartData, protocol, nil
|
||||
|
||||
case "scsi":
|
||||
if data.SmartStatus.Passed == nil && data.ScsiErrorCounterLog == nil && data.Temperature.Current == 0 {
|
||||
return nil, protocol, errNoHealthData
|
||||
}
|
||||
smartData := newSmartData(data, dtype, data.ScsiModelName, data.ScsiRevision, data.Temperature.Current)
|
||||
smartData.Attributes = scsiAttributes(data)
|
||||
return smartData, protocol, nil
|
||||
|
||||
default: // ata
|
||||
var table []smart.AtaSmartAttribute
|
||||
if data.AtaSmartAttributes != nil {
|
||||
table = data.AtaSmartAttributes.Table
|
||||
}
|
||||
if len(table) == 0 && data.SmartStatus.Passed == nil && data.Temperature.Current == 0 && !hasJSONValue(data.AtaDeviceStatistics) {
|
||||
return nil, protocol, errNoHealthData
|
||||
}
|
||||
smartData := newSmartData(data, dtype, data.ModelName, data.FirmwareVersion, data.Temperature.Current)
|
||||
if smartData.Temperature == 0 {
|
||||
var ataDeviceStats smart.AtaDeviceStatistics
|
||||
if temp := findAtaDeviceStatisticsValue(data, &ataDeviceStats, 5, "Current Temperature", 0, 255); temp != nil {
|
||||
smartData.Temperature = uint8(*temp)
|
||||
}
|
||||
}
|
||||
smartData.Attributes = ataAttributes(table)
|
||||
return smartData, protocol, nil
|
||||
}
|
||||
}
|
||||
|
||||
// smartProtocol returns "nvme", "scsi" or "ata" for a smartctl response.
|
||||
func smartProtocol(data *smart.SmartctlOutput) string {
|
||||
switch strings.ToUpper(data.Device.Protocol) {
|
||||
case "NVME":
|
||||
return "nvme"
|
||||
case "SCSI":
|
||||
return "scsi"
|
||||
case "ATA":
|
||||
return "ata"
|
||||
}
|
||||
switch {
|
||||
case data.NVMeSmartHealthInformationLog != nil, strings.EqualFold(data.Device.Type, "nvme"):
|
||||
return "nvme"
|
||||
case data.AtaSmartAttributes != nil:
|
||||
return "ata"
|
||||
case data.ScsiErrorCounterLog != nil, data.ScsiModelName != "":
|
||||
return "scsi"
|
||||
default:
|
||||
return "ata"
|
||||
}
|
||||
}
|
||||
|
||||
// hasJSONValue reports whether a raw JSON value is present and not null.
|
||||
func hasJSONValue(raw json.RawMessage) bool {
|
||||
trimmed := strings.TrimSpace(string(raw))
|
||||
return trimmed != "" && trimmed != "null"
|
||||
}
|
||||
|
||||
// isVirtualDevice checks if a device is a virtual disk that should be filtered out
|
||||
// (e.g., Kubernetes PVCs, QEMU, VirtualBox, etc.)
|
||||
func isVirtualDevice(data *smart.SmartctlOutput) bool {
|
||||
for _, field := range []string{data.ScsiVendor, data.ScsiProduct, data.ModelName, data.ScsiModelName} {
|
||||
field = strings.ToUpper(field)
|
||||
switch {
|
||||
case strings.Contains(field, "IET"), // iSCSI Enterprise Target
|
||||
strings.Contains(field, "VIRTUAL"),
|
||||
strings.Contains(field, "QEMU"),
|
||||
strings.Contains(field, "VBOX"),
|
||||
strings.Contains(field, "VMWARE"),
|
||||
strings.Contains(field, "MSFT"): // Microsoft Hyper-V
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// decodeSmartctlOutput unmarshals smartctl JSON output and rejects devices
|
||||
// without a serial number and virtual devices.
|
||||
func decodeSmartctlOutput(output []byte) (*smart.SmartctlOutput, error) {
|
||||
var data smart.SmartctlOutput
|
||||
if err := json.Unmarshal(output, &data); err != nil {
|
||||
return nil, fmt.Errorf("invalid smartctl JSON: %w", err)
|
||||
}
|
||||
if data.SerialNumber == "" {
|
||||
return nil, errors.New("no serial number")
|
||||
}
|
||||
if isVirtualDevice(&data) {
|
||||
return nil, fmt.Errorf("virtual device %q", data.ModelName)
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// newSmartData fills the fields shared by all smartctl protocols. deviceType is
|
||||
// the exact type used to identify and query the device; when set, it takes
|
||||
// precedence over the generic type reported by smartctl.
|
||||
func newSmartData(data *smart.SmartctlOutput, deviceType, model, firmware string, temperature int64) *smart.SmartData {
|
||||
smartData := &smart.SmartData{
|
||||
ModelName: model,
|
||||
SerialNumber: data.SerialNumber,
|
||||
FirmwareVersion: firmware,
|
||||
Capacity: data.UserCapacity.Bytes,
|
||||
Temperature: uint8(min(max(temperature, 0), 255)),
|
||||
SmartStatus: getSmartStatus(data.SmartStatus.Passed),
|
||||
DiskName: data.Device.Name,
|
||||
DiskType: data.Device.Type,
|
||||
}
|
||||
if deviceType != "" {
|
||||
smartData.DiskType = deviceType
|
||||
}
|
||||
return smartData
|
||||
}
|
||||
|
||||
// getSmartStatus maps smartctl's health check result. A missing result is
|
||||
// UNKNOWN rather than FAILED, so an unreadable status never raises an alert.
|
||||
func getSmartStatus(passed *bool) string {
|
||||
switch {
|
||||
case passed == nil:
|
||||
return "UNKNOWN"
|
||||
case *passed:
|
||||
return "PASSED"
|
||||
default:
|
||||
return "FAILED"
|
||||
}
|
||||
}
|
||||
|
||||
func ataAttributes(table []smart.AtaSmartAttribute) []*smart.SmartAttribute {
|
||||
attributes := make([]*smart.SmartAttribute, 0, len(table))
|
||||
for _, attr := range table {
|
||||
attributes = append(attributes, &smart.SmartAttribute{
|
||||
ID: attr.ID,
|
||||
Name: attr.Name,
|
||||
Value: attr.Value,
|
||||
Worst: attr.Worst,
|
||||
Threshold: attr.Thresh,
|
||||
RawValue: uint64(attr.Raw.Value), // already parsed from raw.string when possible
|
||||
RawString: attr.Raw.String,
|
||||
WhenFailed: attr.WhenFailed,
|
||||
})
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
// findAtaDeviceStatisticsEntry centralizes ATA devstat lookups so additional
|
||||
// metrics can be pulled from the same structure in the future.
|
||||
func findAtaDeviceStatisticsValue(data *smart.SmartctlOutput, ataDeviceStats *smart.AtaDeviceStatistics, entryNumber uint8, entryName string, minValue, maxValue int64) *int64 {
|
||||
if len(ataDeviceStats.Pages) == 0 {
|
||||
if len(data.AtaDeviceStatistics) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := json.Unmarshal(data.AtaDeviceStatistics, ataDeviceStats); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
for pageIdx := range ataDeviceStats.Pages {
|
||||
page := &ataDeviceStats.Pages[pageIdx]
|
||||
if page.Number != entryNumber {
|
||||
continue
|
||||
}
|
||||
for entryIdx := range page.Table {
|
||||
entry := &page.Table[entryIdx]
|
||||
if !strings.EqualFold(entry.Name, entryName) {
|
||||
continue
|
||||
}
|
||||
if entry.Value == nil || *entry.Value < minValue || *entry.Value > maxValue {
|
||||
return nil
|
||||
}
|
||||
return entry.Value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scsiAttributes(data *smart.SmartctlOutput) []*smart.SmartAttribute {
|
||||
cycles := data.ScsiStartStopCycleCounter
|
||||
attributes := []*smart.SmartAttribute{
|
||||
{Name: "PowerOnHours", RawValue: data.PowerOnTime.Hours},
|
||||
{Name: "PowerOnMinutes", RawValue: data.PowerOnTime.Minutes},
|
||||
{Name: "GrownDefectList", RawValue: data.ScsiGrownDefectList},
|
||||
{Name: "StartStopCycles", RawValue: cycles.AccumulatedStartStopCycles},
|
||||
{Name: "LoadUnloadCycles", RawValue: cycles.AccumulatedLoadUnloadCycles},
|
||||
{Name: "StartStopSpecified", RawValue: cycles.SpecifiedCycleCountOverDeviceLifetime},
|
||||
{Name: "LoadUnloadSpecified", RawValue: cycles.SpecifiedLoadUnloadCountOverDeviceLifetime},
|
||||
}
|
||||
|
||||
var errorLog smart.ScsiErrorCounterLog
|
||||
if data.ScsiErrorCounterLog != nil {
|
||||
errorLog = *data.ScsiErrorCounterLog
|
||||
}
|
||||
for _, counter := range []struct {
|
||||
prefix string
|
||||
stats smart.ScsiErrorCounter
|
||||
}{{"Read", errorLog.Read}, {"Write", errorLog.Write}, {"Verify", errorLog.Verify}} {
|
||||
attributes = append(attributes,
|
||||
&smart.SmartAttribute{Name: counter.prefix + "TotalErrorsCorrected", RawValue: counter.stats.TotalErrorsCorrected},
|
||||
&smart.SmartAttribute{Name: counter.prefix + "TotalUncorrectedErrors", RawValue: counter.stats.TotalUncorrectedErrors},
|
||||
&smart.SmartAttribute{Name: counter.prefix + "CorrectionAlgorithmInvocations", RawValue: counter.stats.CorrectionAlgorithmInvocations},
|
||||
)
|
||||
if val := parseScsiGigabytesProcessed(counter.stats.GigabytesProcessed); val >= 0 {
|
||||
attributes = append(attributes, &smart.SmartAttribute{Name: counter.prefix + "GigabytesProcessed", RawValue: uint64(val)})
|
||||
}
|
||||
}
|
||||
return attributes
|
||||
}
|
||||
|
||||
func parseScsiGigabytesProcessed(value string) int64 {
|
||||
if value == "" {
|
||||
return -1
|
||||
}
|
||||
normalized := strings.ReplaceAll(value, ",", "")
|
||||
parsed, err := strconv.ParseInt(normalized, 10, 64)
|
||||
if err != nil {
|
||||
return -1
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// nvmeAttributes maps the NVMe health log, which does not follow the ATA
|
||||
// attribute format, to SmartAttributes.
|
||||
func nvmeAttributes(log *smart.NVMeSmartHealthInformationLog, temperature uint8) []*smart.SmartAttribute {
|
||||
return []*smart.SmartAttribute{
|
||||
{Name: "CriticalWarning", RawValue: log.CriticalWarning},
|
||||
{Name: "Temperature", RawValue: uint64(temperature)},
|
||||
{Name: "AvailableSpare", RawValue: log.AvailableSpare},
|
||||
{Name: "AvailableSpareThreshold", RawValue: log.AvailableSpareThreshold},
|
||||
{Name: "PercentageUsed", RawValue: log.PercentageUsed},
|
||||
{Name: "DataUnitsRead", RawValue: log.DataUnitsRead},
|
||||
{Name: "DataUnitsWritten", RawValue: log.DataUnitsWritten},
|
||||
{Name: "HostReads", RawValue: log.HostReads},
|
||||
{Name: "HostWrites", RawValue: log.HostWrites},
|
||||
{Name: "ControllerBusyTime", RawValue: log.ControllerBusyTime},
|
||||
{Name: "PowerCycles", RawValue: log.PowerCycles},
|
||||
{Name: "PowerOnHours", RawValue: log.PowerOnHours},
|
||||
{Name: "UnsafeShutdowns", RawValue: log.UnsafeShutdowns},
|
||||
{Name: "MediaErrors", RawValue: log.MediaErrors},
|
||||
{Name: "NumErrLogEntries", RawValue: log.NumErrLogEntries},
|
||||
{Name: "WarningTempTime", RawValue: log.WarningTempTime},
|
||||
{Name: "CriticalCompTime", RawValue: log.CriticalCompTime},
|
||||
}
|
||||
}
|
||||
|
||||
// lookupDarwinNvmeCapacity returns the capacity in bytes for a given NVMe serial number on Darwin.
|
||||
// It uses system_profiler SPNVMeDataType to get capacity since Apple SSDs don't report user_capacity
|
||||
// via smartctl. Results are cached after the first call via sync.Once.
|
||||
func (sm *SmartManager) lookupDarwinNvmeCapacity(serial string) uint64 {
|
||||
sm.darwinNvmeOnce.Do(func() {
|
||||
sm.darwinNvmeCapacity = make(map[string]uint64)
|
||||
|
||||
provider := sm.darwinNvmeProvider
|
||||
if provider == nil {
|
||||
provider = func() ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
return exec.CommandContext(ctx, "system_profiler", "SPNVMeDataType", "-json").Output()
|
||||
}
|
||||
}
|
||||
|
||||
out, err := provider()
|
||||
if err != nil {
|
||||
slog.Debug("system_profiler NVMe lookup failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
var result struct {
|
||||
SPNVMeDataType []struct {
|
||||
Items []struct {
|
||||
DeviceSerial string `json:"device_serial"`
|
||||
SizeInBytes uint64 `json:"size_in_bytes"`
|
||||
} `json:"_items"`
|
||||
} `json:"SPNVMeDataType"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &result); err != nil {
|
||||
slog.Debug("system_profiler NVMe parse failed", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, controller := range result.SPNVMeDataType {
|
||||
for _, item := range controller.Items {
|
||||
if item.DeviceSerial != "" && item.SizeInBytes > 0 {
|
||||
sm.darwinNvmeCapacity[item.DeviceSerial] = item.SizeInBytes
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
return sm.darwinNvmeCapacity[serial]
|
||||
}
|
||||
+839
-1110
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,228 @@
|
||||
{
|
||||
"json_format_version": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"smartctl": {
|
||||
"version": [
|
||||
7,
|
||||
5
|
||||
],
|
||||
"pre_release": false,
|
||||
"argv": [
|
||||
"smartctl",
|
||||
"-d",
|
||||
"nvme",
|
||||
"-a",
|
||||
"--json=c",
|
||||
"-n",
|
||||
"standby",
|
||||
"/dev/nvme0"
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"string": "Read NVMe SMART/Health Information (NSID 0xffffffff) failed: NVME_IOCTL_ADMIN_CMD: Permission denied",
|
||||
"severity": "error"
|
||||
}
|
||||
],
|
||||
"exit_status": 4
|
||||
},
|
||||
"device": {
|
||||
"name": "/dev/nvme0",
|
||||
"info_name": "/dev/nvme0",
|
||||
"type": "nvme",
|
||||
"protocol": "NVMe"
|
||||
},
|
||||
"model_name": "HighRel 512GB SSD",
|
||||
"serial_number": "MP51W00000001",
|
||||
"firmware_version": "SN14665",
|
||||
"nvme_pci_vendor": {
|
||||
"id": 7755,
|
||||
"subsystem_id": 7755
|
||||
},
|
||||
"nvme_ieee_oui_identifier": 3824167,
|
||||
"nvme_total_capacity": 512110190592,
|
||||
"nvme_unallocated_capacity": 0,
|
||||
"nvme_controller_id": 0,
|
||||
"nvme_version": {
|
||||
"string": "1.4",
|
||||
"value": 66560
|
||||
},
|
||||
"nvme_number_of_namespaces": 1,
|
||||
"nvme_namespaces": [
|
||||
{
|
||||
"id": 1,
|
||||
"size": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"capacity": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"utilization": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"formatted_lba_size": 512,
|
||||
"eui64": {
|
||||
"oui": 3824167,
|
||||
"ext_id": 1234567890
|
||||
},
|
||||
"features": {
|
||||
"value": 0,
|
||||
"thin_provisioning": false,
|
||||
"na_fields": false,
|
||||
"dealloc_or_unwritten_block_error": false,
|
||||
"uid_reuse": false,
|
||||
"np_fields": false,
|
||||
"other": 0
|
||||
},
|
||||
"lba_formats": [
|
||||
{
|
||||
"formatted": true,
|
||||
"data_bytes": 512,
|
||||
"metadata_bytes": 0,
|
||||
"relative_performance": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"user_capacity": {
|
||||
"blocks": 1000215216,
|
||||
"bytes": 512110190592
|
||||
},
|
||||
"logical_block_size": 512,
|
||||
"smart_support": {
|
||||
"available": true,
|
||||
"enabled": true
|
||||
},
|
||||
"nvme_firmware_update_capabilities": {
|
||||
"value": 26,
|
||||
"slots": 5,
|
||||
"first_slot_is_read_only": false,
|
||||
"activiation_without_reset": true,
|
||||
"multiple_update_detection": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_optional_admin_commands": {
|
||||
"value": 23,
|
||||
"security_send_receive": true,
|
||||
"format_nvm": true,
|
||||
"firmware_download": true,
|
||||
"namespace_management": false,
|
||||
"self_test": true,
|
||||
"directives": false,
|
||||
"mi_send_receive": false,
|
||||
"virtualization_management": false,
|
||||
"doorbell_buffer_config": false,
|
||||
"get_lba_status": false,
|
||||
"command_and_feature_lockdown": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_optional_nvm_commands": {
|
||||
"value": 31,
|
||||
"compare": true,
|
||||
"write_uncorrectable": true,
|
||||
"dataset_management": true,
|
||||
"write_zeroes": true,
|
||||
"save_select_feature_nonzero": true,
|
||||
"reservations": false,
|
||||
"timestamp": false,
|
||||
"verify": false,
|
||||
"copy": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_log_page_attributes": {
|
||||
"value": 6,
|
||||
"smart_health_per_namespace": false,
|
||||
"commands_effects_log": true,
|
||||
"extended_get_log_page_cmd": true,
|
||||
"telemetry_log": false,
|
||||
"persistent_event_log": false,
|
||||
"supported_log_pages_log": false,
|
||||
"telemetry_data_area_4": false,
|
||||
"other": 0
|
||||
},
|
||||
"nvme_maximum_data_transfer_pages": 128,
|
||||
"nvme_composite_temperature_threshold": {
|
||||
"warning": 90,
|
||||
"critical": 95
|
||||
},
|
||||
"temperature": {
|
||||
"op_limit_max": 90,
|
||||
"critical_limit_max": 95
|
||||
},
|
||||
"nvme_power_states": [
|
||||
{
|
||||
"non_operational_state": false,
|
||||
"relative_read_latency": 0,
|
||||
"relative_read_throughput": 0,
|
||||
"relative_write_latency": 0,
|
||||
"relative_write_throughput": 0,
|
||||
"entry_latency_us": 0,
|
||||
"exit_latency_us": 0,
|
||||
"max_power": {
|
||||
"value": 650,
|
||||
"scale": 2,
|
||||
"units_per_watt": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": false,
|
||||
"relative_read_latency": 1,
|
||||
"relative_read_throughput": 1,
|
||||
"relative_write_latency": 1,
|
||||
"relative_write_throughput": 1,
|
||||
"entry_latency_us": 0,
|
||||
"exit_latency_us": 0,
|
||||
"max_power": {
|
||||
"value": 580,
|
||||
"scale": 2,
|
||||
"units_per_watt": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": false,
|
||||
"relative_read_latency": 2,
|
||||
"relative_read_throughput": 2,
|
||||
"relative_write_latency": 2,
|
||||
"relative_write_throughput": 2,
|
||||
"entry_latency_us": 0,
|
||||
"exit_latency_us": 0,
|
||||
"max_power": {
|
||||
"value": 360,
|
||||
"scale": 2,
|
||||
"units_per_watt": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": true,
|
||||
"relative_read_latency": 3,
|
||||
"relative_read_throughput": 3,
|
||||
"relative_write_latency": 3,
|
||||
"relative_write_throughput": 3,
|
||||
"entry_latency_us": 5000,
|
||||
"exit_latency_us": 10000,
|
||||
"max_power": {
|
||||
"value": 500,
|
||||
"scale": 1,
|
||||
"units_per_watt": 10000
|
||||
}
|
||||
},
|
||||
{
|
||||
"non_operational_state": true,
|
||||
"relative_read_latency": 4,
|
||||
"relative_read_throughput": 4,
|
||||
"relative_write_latency": 4,
|
||||
"relative_write_throughput": 4,
|
||||
"entry_latency_us": 8000,
|
||||
"exit_latency_us": 45000,
|
||||
"max_power": {
|
||||
"value": 25,
|
||||
"scale": 1,
|
||||
"units_per_watt": 10000
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"smartctl": {
|
||||
"exit_status": 0
|
||||
},
|
||||
"device": {
|
||||
"name": "/dev/sda",
|
||||
"info_name": "/dev/sda [SAT]",
|
||||
"type": "sat",
|
||||
"protocol": "ATA"
|
||||
},
|
||||
"model_name": "TOSHIBA MG03ACA200",
|
||||
"serial_number": "Z4K2A01FS",
|
||||
"firmware_version": "5701",
|
||||
"user_capacity": { "blocks": 5860533168, "bytes": 3000592982016 },
|
||||
"smart_status": { "passed": true },
|
||||
"temperature": { "current": 38 },
|
||||
"ata_smart_attributes": {
|
||||
"revision": 16,
|
||||
"table": [
|
||||
{ "id": 1, "name": "Raw_Read_Error_Rate", "value": 100, "worst": 100, "thresh": 16, "when_failed": "", "raw": { "value": 0, "string": "0" } },
|
||||
{ "id": 5, "name": "Reallocated_Sector_Ct", "value": 100, "worst": 100, "thresh": 5, "when_failed": "", "raw": { "value": 0, "string": "0" } },
|
||||
{ "id": 9, "name": "Power_On_Hours", "value": 1, "worst": 1, "thresh": 0, "when_failed": "", "raw": { "value": 43953, "string": "43953" } },
|
||||
{ "id": 194, "name": "Temperature_Celsius", "value": 62, "worst": 47, "thresh": 0, "when_failed": "", "raw": { "value": 38, "string": "38" } }
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"json_format_version": [
|
||||
1,
|
||||
0
|
||||
],
|
||||
"smartctl": {
|
||||
"version": [
|
||||
7,
|
||||
5
|
||||
],
|
||||
"pre_release": false,
|
||||
"argv": [
|
||||
"smartctl",
|
||||
"-d",
|
||||
"sat",
|
||||
"-a",
|
||||
"--json=c",
|
||||
"-n",
|
||||
"standby",
|
||||
"/dev/sda"
|
||||
],
|
||||
"messages": [
|
||||
{
|
||||
"string": "Device is in SLEEP mode, exit(2)",
|
||||
"severity": "information"
|
||||
}
|
||||
],
|
||||
"exit_status": 2
|
||||
},
|
||||
"device": {
|
||||
"name": "/dev/sda",
|
||||
"info_name": "/dev/sda [SAT]",
|
||||
"type": "sat",
|
||||
"protocol": "ATA"
|
||||
},
|
||||
"power_mode": {
|
||||
"ata_value": -1,
|
||||
"name": "SLEEP"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"smartctl": {
|
||||
"exit_status": 0
|
||||
},
|
||||
"device": {
|
||||
"name": "/dev/sda",
|
||||
"info_name": "/dev/sda",
|
||||
"type": "scsi",
|
||||
"protocol": "SCSI"
|
||||
},
|
||||
"scsi_vendor": "TOSHIBA",
|
||||
"scsi_product": "MG03ACA200",
|
||||
"scsi_model_name": "TOSHIBA MG03ACA200",
|
||||
"scsi_revision": "5701",
|
||||
"serial_number": "Z4K2A01FS",
|
||||
"user_capacity": { "blocks": 5860533168, "bytes": 3000592982016 },
|
||||
"smart_status": { "passed": null },
|
||||
"temperature": { "current": 0, "drive_trip": 0 },
|
||||
"power_on_time": { "hours": 0, "minutes": 0 }
|
||||
}
|
||||
@@ -21,7 +21,7 @@ type SmartctlOutput struct {
|
||||
FirmwareVersion string `json:"firmware_version"`
|
||||
UserCapacity UserCapacity `json:"user_capacity"`
|
||||
SmartStatus struct {
|
||||
Passed bool `json:"passed"`
|
||||
Passed *bool `json:"passed"` // nil when smartctl could not read the health status
|
||||
} `json:"smart_status"`
|
||||
Temperature struct {
|
||||
Current int64 `json:"current"`
|
||||
|
||||
Reference in New Issue
Block a user