mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-21 22:02:34 +00:00
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>
334 lines
12 KiB
Go
334 lines
12 KiB
Go
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]
|
|
}
|