mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-24 12:13:28 +00:00
2335 lines
69 KiB
Go
2335 lines
69 KiB
Go
package hostagent
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/diskinventory"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/fsfilters"
|
|
)
|
|
|
|
const smartctlComponent = "smartctl_collector"
|
|
const maxCommandOutputBytes = 1 << 20 // 1 MiB
|
|
const smartctlStandbyExitStatus = 3
|
|
|
|
var (
|
|
errCommandOutputTooLarge = errors.New("command output exceeds size limit")
|
|
errSMARTDataUnavailable = errors.New("smart data unavailable for device")
|
|
execLookPath = exec.LookPath
|
|
smartRunCommandOutput = func(ctx context.Context, name string, args ...string) ([]byte, error) {
|
|
return runCommandOutputLimited(ctx, maxCommandOutputBytes, name, args...)
|
|
}
|
|
readDir = os.ReadDir
|
|
smartctlReadFile = os.ReadFile
|
|
smartctlEvalSymlinks = filepath.EvalSymlinks
|
|
|
|
timeNow = time.Now
|
|
runtimeGOOS = runtime.GOOS
|
|
|
|
smartCollectionConcurrency = 6
|
|
smartCollectionParallelThreshold = 12
|
|
)
|
|
|
|
// DiskSMART represents S.M.A.R.T. data for a single disk.
|
|
type DiskSMART struct {
|
|
Device string `json:"device"` // Block device name (e.g., sda, nvme0n1)
|
|
Model string `json:"model,omitempty"` // Disk model
|
|
Serial string `json:"serial,omitempty"` // Serial number
|
|
WWN string `json:"wwn,omitempty"` // World Wide Name
|
|
Type string `json:"type,omitempty"` // Transport type: sata, sas, nvme
|
|
Controller string `json:"controller,omitempty"` // PCI/controller association when reported
|
|
Target string `json:"target,omitempty"` // HCTL or smartctl controller-member target
|
|
SizeBytes int64 `json:"sizeBytes,omitempty"` // Capacity in bytes (0 when unknown)
|
|
Temperature int `json:"temperature"` // Temperature in Celsius
|
|
Health string `json:"health,omitempty"` // PASSED, FAILED, UNKNOWN
|
|
Standby bool `json:"standby,omitempty"` // True if disk was in standby
|
|
Collection *diskinventory.CollectionStatus `json:"collection,omitempty"`
|
|
Attributes *SMARTAttributes `json:"attributes,omitempty"`
|
|
LastUpdated time.Time `json:"lastUpdated"` // When this reading was taken
|
|
}
|
|
|
|
// SMARTAttributes holds normalized SMART attributes for both SATA and NVMe disks.
|
|
// Pointer fields distinguish zero from absent.
|
|
type SMARTAttributes struct {
|
|
// Common attributes
|
|
PowerOnHours *int64 `json:"powerOnHours,omitempty"`
|
|
PowerCycles *int64 `json:"powerCycles,omitempty"`
|
|
|
|
// SATA-specific (by ATA attribute ID)
|
|
ReallocatedSectors *int64 `json:"reallocatedSectors,omitempty"` // ID 5
|
|
PendingSectors *int64 `json:"pendingSectors,omitempty"` // ID 197
|
|
OfflineUncorrectable *int64 `json:"offlineUncorrectable,omitempty"` // ID 198
|
|
UDMACRCErrors *int64 `json:"udmaCrcErrors,omitempty"` // ID 199
|
|
|
|
// NVMe-specific
|
|
PercentageUsed *int `json:"percentageUsed,omitempty"`
|
|
AvailableSpare *int `json:"availableSpare,omitempty"`
|
|
MediaErrors *int64 `json:"mediaErrors,omitempty"`
|
|
UnsafeShutdowns *int64 `json:"unsafeShutdowns,omitempty"`
|
|
}
|
|
|
|
type nvmeSmartHealthInformationLogJSON struct {
|
|
Temperature int `json:"temperature"`
|
|
AvailableSpare *int `json:"available_spare"`
|
|
PercentageUsed *int `json:"percentage_used"`
|
|
PowerOnHours *int64 `json:"power_on_hours"`
|
|
UnsafeShutdowns *int64 `json:"unsafe_shutdowns"`
|
|
MediaErrors *int64 `json:"media_errors"`
|
|
PowerCycles *int64 `json:"power_cycles"`
|
|
}
|
|
|
|
type lsblkJSON struct {
|
|
Blockdevices []lsblkDevice `json:"blockdevices"`
|
|
}
|
|
|
|
type lsblkDevice struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Tran string `json:"tran"`
|
|
Model string `json:"model"`
|
|
Vendor string `json:"vendor"`
|
|
Subsystems string `json:"subsystems"`
|
|
}
|
|
|
|
// linuxSMARTVirtualPrefixes are device name prefixes for virtual/logical
|
|
// devices that cannot provide SMART data.
|
|
var linuxSMARTVirtualPrefixes = []string{
|
|
"dm-",
|
|
"drbd",
|
|
"loop",
|
|
"md",
|
|
"nbd",
|
|
"pmem",
|
|
"ram",
|
|
"rbd",
|
|
"vd",
|
|
"xvd",
|
|
"zd",
|
|
"zram",
|
|
}
|
|
|
|
// linuxSMARTVirtualMetadataTokens are vendor/model substrings indicating a
|
|
// virtual disk that cannot provide SMART data.
|
|
var linuxSMARTVirtualMetadataTokens = []string{
|
|
"hyper-v",
|
|
"msft virtual",
|
|
"parallels",
|
|
"qemu",
|
|
"vbox",
|
|
"virtual disk",
|
|
"virtual hd",
|
|
"virtualbox",
|
|
"vmware",
|
|
}
|
|
|
|
// linuxSMARTVirtualSubsystemTokens are lsblk SUBSYSTEMS substrings
|
|
// indicating virtual block devices.
|
|
var linuxSMARTVirtualSubsystemTokens = []string{
|
|
"drbd",
|
|
"nbd",
|
|
"vmbus",
|
|
"virtio",
|
|
"xen",
|
|
"zfs",
|
|
}
|
|
|
|
// smartctlJSON represents the JSON output from smartctl --json=o.
|
|
type smartctlJSON struct {
|
|
Smartctl struct {
|
|
Output []string `json:"output"`
|
|
} `json:"smartctl"`
|
|
Device struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type"`
|
|
Protocol string `json:"protocol"`
|
|
} `json:"device"`
|
|
ModelFamily string `json:"model_family"`
|
|
ModelName string `json:"model_name"`
|
|
SerialNumber string `json:"serial_number"`
|
|
WWN struct {
|
|
NAA uint64 `json:"naa"`
|
|
OUI uint64 `json:"oui"`
|
|
ID uint64 `json:"id"`
|
|
} `json:"wwn"`
|
|
UserCapacity struct {
|
|
Bytes int64 `json:"bytes"`
|
|
} `json:"user_capacity"`
|
|
NVMeTotalCapacity int64 `json:"nvme_total_capacity"`
|
|
SmartStatus *struct {
|
|
Passed bool `json:"passed"`
|
|
} `json:"smart_status,omitempty"`
|
|
SCSITransportProtocol struct {
|
|
Name string `json:"name"`
|
|
} `json:"scsi_transport_protocol"`
|
|
PowerOnTime *struct {
|
|
Hours int64 `json:"hours"`
|
|
} `json:"power_on_time"`
|
|
SCSIGrownDefectList *int64 `json:"scsi_grown_defect_list"`
|
|
SCSIPercentageUsedEnduranceIndicator *int `json:"scsi_percentage_used_endurance_indicator"`
|
|
Temperature struct {
|
|
Current int `json:"current"`
|
|
} `json:"temperature"`
|
|
ATASmartAttributes struct {
|
|
Table []struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Value int `json:"value"`
|
|
Worst int `json:"worst"`
|
|
Thresh int `json:"thresh"`
|
|
Raw struct {
|
|
Value int64 `json:"value"`
|
|
String string `json:"string"`
|
|
} `json:"raw"`
|
|
} `json:"table"`
|
|
} `json:"ata_smart_attributes"`
|
|
ATASCTStatus struct {
|
|
Current struct {
|
|
Value int `json:"value"`
|
|
} `json:"current"`
|
|
} `json:"ata_sct_status"`
|
|
NVMeSmartHealthInformationLog *nvmeSmartHealthInformationLogJSON `json:"nvme_smart_health_information_log"`
|
|
PowerMode string `json:"power_mode"`
|
|
}
|
|
|
|
type smartTextFallback struct {
|
|
Model string
|
|
Serial string
|
|
Type string
|
|
Health string
|
|
Temperature int
|
|
Standby bool
|
|
}
|
|
|
|
var (
|
|
smartTextTempAttributeRE = regexp.MustCompile(`^\s*(190|194)\s+\S+.*-\s+(\d{1,3})\b`)
|
|
smartTextCurrentTempRE = regexp.MustCompile(`(?i)^current(?: drive)? temperature:\s*(\d{1,3})\b`)
|
|
smartTextTemperatureRE = regexp.MustCompile(`(?i)^temperature:\s*(\d{1,3})\b`)
|
|
linuxDirectSATDeviceRE = regexp.MustCompile(`^((sd|hd)[a-z]+|sata[0-9]+)$`)
|
|
pciControllerAddressRE = regexp.MustCompile(`(?i)^[0-9a-f]{4}:[0-9a-f]{2}:[0-9a-f]{2}\.[0-7]$`)
|
|
)
|
|
|
|
type smartctlTarget struct {
|
|
Path string
|
|
DeviceType string
|
|
NativeTransport string
|
|
}
|
|
|
|
func (t smartctlTarget) displayName() string {
|
|
name := filepath.Base(strings.TrimSpace(t.Path))
|
|
if name == "" || name == "." || name == string(filepath.Separator) {
|
|
name = strings.TrimSpace(t.Path)
|
|
}
|
|
if t.DeviceType == "" {
|
|
return name
|
|
}
|
|
return name + " [" + t.DeviceType + "]"
|
|
}
|
|
|
|
// CollectSMARTLocal collects S.M.A.R.T. data from all local block devices.
|
|
// The diskExclude parameter specifies patterns for devices to skip (e.g., "sda", "/dev/nvme*", "*cache*").
|
|
func CollectSMARTLocal(ctx context.Context, diskExclude []string) ([]DiskSMART, error) {
|
|
return CollectSMARTLocalWithUnraid(ctx, diskExclude, nil)
|
|
}
|
|
|
|
// CollectSMARTLocalWithUnraid collects local SMART data while treating native
|
|
// Unraid membership, transport, and spin state as authoritative hints. Native
|
|
// array state is never derived from SMART success or failure.
|
|
func CollectSMARTLocalWithUnraid(ctx context.Context, diskExclude []string, unraid *agentshost.UnraidStorage) ([]DiskSMART, error) {
|
|
enumerationCtx, cancelEnumeration := context.WithTimeout(ctx, 10*time.Second)
|
|
targets, err := listSMARTTargets(enumerationCtx, diskExclude)
|
|
cancelEnumeration()
|
|
if err != nil {
|
|
log.Debug().Err(err).Msg("failed to list block devices for SMART collection")
|
|
return nil, fmt.Errorf("list block devices for SMART collection: %w", err)
|
|
}
|
|
|
|
targets, nativeStandby := applyUnraidSMARTInventory(targets, diskExclude, unraid)
|
|
|
|
type smartOutcome struct {
|
|
smart *DiskSMART
|
|
err error
|
|
}
|
|
outcomes := make([]smartOutcome, len(targets))
|
|
if len(targets) > 0 {
|
|
workerCount := smartCollectionConcurrency
|
|
if len(targets) < smartCollectionParallelThreshold {
|
|
workerCount = 1
|
|
}
|
|
if workerCount < 1 {
|
|
workerCount = 1
|
|
}
|
|
if workerCount > len(targets) {
|
|
workerCount = len(targets)
|
|
}
|
|
jobs := make(chan int)
|
|
var workers sync.WaitGroup
|
|
workers.Add(workerCount)
|
|
for worker := 0; worker < workerCount; worker++ {
|
|
go func() {
|
|
defer workers.Done()
|
|
for index := range jobs {
|
|
outcomes[index].smart, outcomes[index].err = collectSMARTTarget(ctx, targets[index])
|
|
}
|
|
}()
|
|
}
|
|
for index := range targets {
|
|
jobs <- index
|
|
}
|
|
close(jobs)
|
|
workers.Wait()
|
|
}
|
|
|
|
results := append([]DiskSMART(nil), nativeStandby...)
|
|
var missed []smartctlTarget
|
|
collected := make(map[string]struct{}, len(targets))
|
|
multiplexed := make(map[string]struct{})
|
|
for _, target := range targets {
|
|
block := canonicalBlockDeviceForScanPath(target.Path)
|
|
if isMultiplexedDeviceType(target.DeviceType) && block != "" {
|
|
multiplexed[block] = struct{}{}
|
|
}
|
|
}
|
|
for index, target := range targets {
|
|
block := canonicalBlockDeviceForScanPath(target.Path)
|
|
smart, err := outcomes[index].smart, outcomes[index].err
|
|
if err != nil {
|
|
if errors.Is(err, errSMARTDataUnavailable) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_no_smart_data").
|
|
Str("device", target.displayName()).
|
|
Msg("Device returned no usable SMART data, skipping")
|
|
} else {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "collect_device_smart_failed").
|
|
Str("device", target.Path).
|
|
Err(err).
|
|
Msg("Failed to collect SMART data for device")
|
|
}
|
|
missed = append(missed, target)
|
|
continue
|
|
}
|
|
if smart == nil {
|
|
missed = append(missed, target)
|
|
continue
|
|
}
|
|
refineLinuxBlockDeviceIdentity(smart, target)
|
|
// The refine step can rename the device (nvme0 -> nvme0n1), so re-apply
|
|
// exclusions against the canonical name the user actually sees.
|
|
if matchesDeviceExclude(smart.Device, "/dev/"+smart.Device, diskExclude) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_excluded_device").
|
|
Str("device", smart.Device).
|
|
Msg("Skipping excluded device for SMART collection")
|
|
if block != "" {
|
|
collected[block] = struct{}{}
|
|
}
|
|
continue
|
|
}
|
|
results = append(results, *smart)
|
|
if block != "" {
|
|
collected[block] = struct{}{}
|
|
}
|
|
}
|
|
|
|
results = append(results, linuxIdentityOnlyDisks(missed, collected, multiplexed, diskExclude)...)
|
|
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "collect_local_complete").
|
|
Int("devices_discovered", len(targets)).
|
|
Int("devices_collected", len(results)).
|
|
Msg("Completed SMART collection for local devices")
|
|
|
|
return results, nil
|
|
}
|
|
|
|
func applyUnraidSMARTInventory(targets []smartctlTarget, diskExclude []string, unraid *agentshost.UnraidStorage) ([]smartctlTarget, []DiskSMART) {
|
|
if unraid == nil || len(unraid.Disks) == 0 {
|
|
return targets, nil
|
|
}
|
|
|
|
nativeByBlock := make(map[string]agentshost.UnraidDisk, len(unraid.Disks))
|
|
for _, disk := range unraid.Disks {
|
|
block := canonicalBlockDeviceForScanPath(disk.Device)
|
|
if block == "" || matchesDeviceExclude(block, "/dev/"+block, diskExclude) {
|
|
continue
|
|
}
|
|
nativeByBlock[block] = disk
|
|
}
|
|
|
|
filtered := make([]smartctlTarget, 0, len(targets))
|
|
standbyByBlock := make(map[string]DiskSMART)
|
|
for _, target := range targets {
|
|
block := canonicalBlockDeviceForScanPath(target.Path)
|
|
native, ok := nativeByBlock[block]
|
|
if !ok {
|
|
filtered = append(filtered, target)
|
|
continue
|
|
}
|
|
target.NativeTransport = normalizeSMARTTransport(native.Transport)
|
|
if !native.SpunDown {
|
|
filtered = append(filtered, target)
|
|
continue
|
|
}
|
|
standbyByBlock[block] = nativeStandbySMARTDisk(block, native)
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_native_standby").
|
|
Str("device", block).
|
|
Msg("Skipping SMART commands for disk reported spun down by Unraid")
|
|
}
|
|
|
|
// A spun-down native member can be absent from smartctl's non-opening scan.
|
|
// Preserve its identity without touching the device.
|
|
for block, native := range nativeByBlock {
|
|
if native.SpunDown {
|
|
standbyByBlock[block] = nativeStandbySMARTDisk(block, native)
|
|
}
|
|
}
|
|
|
|
standby := make([]DiskSMART, 0, len(standbyByBlock))
|
|
for _, disk := range standbyByBlock {
|
|
standby = append(standby, disk)
|
|
}
|
|
sort.Slice(standby, func(i, j int) bool { return standby[i].Device < standby[j].Device })
|
|
return filtered, standby
|
|
}
|
|
|
|
func nativeStandbySMARTDisk(block string, disk agentshost.UnraidDisk) DiskSMART {
|
|
serialStatus := diskinventory.Missing("unraid", "disk serial was not reported")
|
|
if strings.TrimSpace(disk.Serial) != "" {
|
|
serialStatus = diskinventory.Available("unraid")
|
|
}
|
|
return DiskSMART{
|
|
Device: block,
|
|
Model: strings.TrimSpace(disk.Model),
|
|
Serial: strings.TrimSpace(disk.Serial),
|
|
Type: normalizeSMARTTransport(disk.Transport),
|
|
SizeBytes: disk.SizeBytes,
|
|
Health: "UNKNOWN",
|
|
Standby: true,
|
|
Collection: &diskinventory.CollectionStatus{
|
|
Serial: serialStatus,
|
|
Temperature: diskinventory.Unavailable("unraid", "disk is reported spun down"),
|
|
},
|
|
LastUpdated: timeNow(),
|
|
}
|
|
}
|
|
|
|
func normalizeSMARTTransport(transport string) string {
|
|
switch normalized := strings.ToLower(strings.TrimSpace(transport)); normalized {
|
|
case "ata":
|
|
return "sata"
|
|
case "sata", "sas", "usb", "nvme":
|
|
return normalized
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func listSMARTTargets(ctx context.Context, diskExclude []string) ([]smartctlTarget, error) {
|
|
if runtimeGOOS == "linux" {
|
|
return listSMARTTargetsLinux(ctx, diskExclude)
|
|
}
|
|
|
|
devices, err := listBlockDevices(ctx, diskExclude)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return smartctlTargetsFromDevices(devices), nil
|
|
}
|
|
|
|
func smartctlTargetsFromDevices(devices []string) []smartctlTarget {
|
|
if len(devices) == 0 {
|
|
return nil
|
|
}
|
|
targets := make([]smartctlTarget, 0, len(devices))
|
|
for _, device := range devices {
|
|
targets = append(targets, smartctlTarget{Path: device})
|
|
}
|
|
return targets
|
|
}
|
|
|
|
func listSMARTTargetsLinux(ctx context.Context, diskExclude []string) ([]smartctlTarget, error) {
|
|
scanTargets, scanErr := listSMARTTargetsLinuxFromScan(ctx, diskExclude)
|
|
if scanErr != nil {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Err(scanErr).
|
|
Msg("Failed to enumerate Linux SMART targets via smartctl --scan, relying on block device discovery")
|
|
}
|
|
|
|
// smartctl's scan alone can omit devices it cannot classify (#1483: a SATA
|
|
// SSD missing while two NVMe controllers were reported). The kernel block
|
|
// device list is the ground truth for which disks exist; the non-opening
|
|
// scan only contributes device-type hints. Union the two.
|
|
devices, devErr := listBlockDevicesLinux(ctx, diskExclude)
|
|
if devErr != nil {
|
|
if len(scanTargets) > 0 {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Err(devErr).
|
|
Msg("Block device discovery failed; using smartctl --scan targets only")
|
|
return scanTargets, nil
|
|
}
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
return nil, devErr
|
|
}
|
|
|
|
return unionSMARTTargets(scanTargets, devices), nil
|
|
}
|
|
|
|
// unionSMARTTargets returns scanTargets plus an untyped target for every block
|
|
// device that no scan target covers. A scan target covers its own path's
|
|
// basename and, for an NVMe controller, the namespace it canonicalizes to.
|
|
func unionSMARTTargets(scanTargets []smartctlTarget, devices []string) []smartctlTarget {
|
|
covered := make(map[string]struct{}, len(scanTargets)*2)
|
|
for _, target := range scanTargets {
|
|
if name := filepath.Base(strings.TrimSpace(target.Path)); name != "" && name != "." {
|
|
covered[name] = struct{}{}
|
|
}
|
|
if block := canonicalBlockDeviceForScanPath(target.Path); block != "" {
|
|
covered[block] = struct{}{}
|
|
}
|
|
}
|
|
|
|
targets := append([]smartctlTarget(nil), scanTargets...)
|
|
for _, device := range devices {
|
|
name := filepath.Base(strings.TrimSpace(device))
|
|
if name == "" || name == "." {
|
|
continue
|
|
}
|
|
if _, ok := covered[name]; ok {
|
|
continue
|
|
}
|
|
covered[name] = struct{}{}
|
|
targets = append(targets, smartctlTarget{Path: device})
|
|
}
|
|
return targets
|
|
}
|
|
|
|
func listSMARTTargetsLinuxFromScan(ctx context.Context, diskExclude []string) ([]smartctlTarget, error) {
|
|
smartctlPath, err := resolveSmartctlPath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("look up smartctl binary: %w", err)
|
|
}
|
|
|
|
output, err := smartRunCommandOutput(ctx, smartctlPath, "--scan")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return parseSmartctlScanTargets(output, diskExclude), nil
|
|
}
|
|
|
|
func parseSmartctlScanTargets(output []byte, diskExclude []string) []smartctlTarget {
|
|
lines := strings.Split(string(output), "\n")
|
|
targets := make([]smartctlTarget, 0, len(lines))
|
|
typedByPath := make(map[string]bool)
|
|
seen := make(map[string]struct{})
|
|
|
|
for _, rawLine := range lines {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if idx := strings.Index(line, "#"); idx >= 0 {
|
|
line = strings.TrimSpace(line[:idx])
|
|
}
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
fields := strings.Fields(line)
|
|
if len(fields) == 0 {
|
|
continue
|
|
}
|
|
|
|
path := strings.TrimSpace(fields[0])
|
|
if path == "" || (!strings.HasPrefix(path, "/") && !strings.HasPrefix(path, "-")) {
|
|
continue
|
|
}
|
|
|
|
deviceType := ""
|
|
for i := 1; i < len(fields)-1; i++ {
|
|
if fields[i] == "-d" {
|
|
deviceType = strings.TrimSpace(fields[i+1])
|
|
break
|
|
}
|
|
}
|
|
|
|
name := filepath.Base(path)
|
|
if fsfilters.IsVirtualBlockDevice(name) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_virtual_device").
|
|
Str("device", path).
|
|
Msg("Skipping non-physical device reported by smartctl --scan")
|
|
continue
|
|
}
|
|
if matchesDeviceExclude(name, path, diskExclude) {
|
|
continue
|
|
}
|
|
|
|
key := path + "\x00" + deviceType
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = struct{}{}
|
|
if deviceType != "" {
|
|
typedByPath[path] = true
|
|
}
|
|
|
|
targets = append(targets, smartctlTarget{
|
|
Path: path,
|
|
DeviceType: deviceType,
|
|
})
|
|
}
|
|
|
|
if len(targets) == 0 {
|
|
return nil
|
|
}
|
|
|
|
filtered := make([]smartctlTarget, 0, len(targets))
|
|
for _, target := range targets {
|
|
if target.DeviceType == "" && typedByPath[target.Path] {
|
|
continue
|
|
}
|
|
filtered = append(filtered, target)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
// listBlockDevices returns a list of block devices suitable for SMART queries.
|
|
// Devices matching any of the diskExclude patterns are skipped.
|
|
func listBlockDevices(ctx context.Context, diskExclude []string) ([]string, error) {
|
|
if runtimeGOOS == "freebsd" {
|
|
return listBlockDevicesFreeBSD(ctx, diskExclude)
|
|
}
|
|
return listBlockDevicesLinux(ctx, diskExclude)
|
|
}
|
|
|
|
func listBlockDevicesLinux(ctx context.Context, diskExclude []string) ([]string, error) {
|
|
devices, err := listBlockDevicesLinuxFromSysfs(diskExclude)
|
|
if err == nil {
|
|
return devices, nil
|
|
}
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Err(err).
|
|
Msg("sysfs device discovery failed, falling back to lsblk")
|
|
|
|
return listBlockDevicesLinuxFromLSBLK(ctx, diskExclude)
|
|
}
|
|
|
|
func listBlockDevicesLinuxFromSysfs(diskExclude []string) ([]string, error) {
|
|
entries, err := readDir("/sys/block")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var devices []string
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name())
|
|
if name == "" {
|
|
continue
|
|
}
|
|
|
|
devicePath := "/dev/" + name
|
|
if reason := linuxSMARTSkipReasonSysfs(name); reason != "" {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_virtual_device").
|
|
Str("device", devicePath).
|
|
Str("reason", reason).
|
|
Msg("Skipping non-physical device for SMART collection")
|
|
continue
|
|
}
|
|
if matchesDeviceExclude(name, devicePath, diskExclude) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_excluded_device").
|
|
Str("device", devicePath).
|
|
Msg("Skipping excluded device for SMART collection")
|
|
continue
|
|
}
|
|
devices = append(devices, devicePath)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
func linuxSMARTSkipReasonSysfs(name string) string {
|
|
lowerName := strings.ToLower(name)
|
|
for _, prefix := range linuxSMARTVirtualPrefixes {
|
|
if strings.HasPrefix(lowerName, prefix) {
|
|
return "virtual/logical device prefix"
|
|
}
|
|
}
|
|
|
|
blockPath := filepath.Join("/sys/block", name)
|
|
if resolved, err := smartctlEvalSymlinks(blockPath); err == nil && strings.Contains(strings.ToLower(resolved), "/virtual/") {
|
|
return "virtual block device"
|
|
}
|
|
|
|
subsystemPath := filepath.Join(blockPath, "device", "subsystem")
|
|
if resolved, err := smartctlEvalSymlinks(subsystemPath); err == nil {
|
|
lowerResolved := strings.ToLower(resolved)
|
|
for _, token := range linuxSMARTVirtualSubsystemTokens {
|
|
if strings.Contains(lowerResolved, token) {
|
|
return "virtual/logical subsystem"
|
|
}
|
|
}
|
|
}
|
|
|
|
metadata := strings.ToLower(strings.TrimSpace(
|
|
readTrimmedFile(filepath.Join(blockPath, "device", "vendor")) + " " +
|
|
readTrimmedFile(filepath.Join(blockPath, "device", "model")),
|
|
))
|
|
for _, token := range linuxSMARTVirtualMetadataTokens {
|
|
if strings.Contains(metadata, token) {
|
|
return "virtual disk model/vendor signature"
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
func readTrimmedFile(path string) string {
|
|
data, err := smartctlReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func listBlockDevicesLinuxFromLSBLK(ctx context.Context, diskExclude []string) ([]string, error) {
|
|
output, err := smartRunCommandOutput(ctx, "lsblk", "-J", "-d", "-o", "NAME,TYPE,TRAN,MODEL,VENDOR,SUBSYSTEMS")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var data lsblkJSON
|
|
if err := json.Unmarshal(output, &data); err != nil {
|
|
return nil, fmt.Errorf("parse lsblk JSON: %w", err)
|
|
}
|
|
|
|
var devices []string
|
|
for _, disk := range data.Blockdevices {
|
|
if strings.TrimSpace(disk.Name) == "" {
|
|
continue
|
|
}
|
|
|
|
devicePath := "/dev/" + disk.Name
|
|
if reason := linuxSMARTSkipReason(disk); reason != "" {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_virtual_device").
|
|
Str("device", devicePath).
|
|
Str("reason", reason).
|
|
Msg("Skipping non-physical device for SMART collection")
|
|
continue
|
|
}
|
|
if matchesDeviceExclude(disk.Name, devicePath, diskExclude) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_excluded_device").
|
|
Str("device", devicePath).
|
|
Msg("Skipping excluded device for SMART collection")
|
|
continue
|
|
}
|
|
devices = append(devices, devicePath)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
// linuxSMARTSkipReason returns a human-readable reason if the device should be
|
|
// skipped for SMART collection, or "" if the device is a real physical disk.
|
|
func linuxSMARTSkipReason(device lsblkDevice) string {
|
|
if !strings.EqualFold(strings.TrimSpace(device.Type), "disk") {
|
|
return "not a whole disk"
|
|
}
|
|
|
|
name := strings.ToLower(strings.TrimSpace(device.Name))
|
|
for _, prefix := range linuxSMARTVirtualPrefixes {
|
|
if strings.HasPrefix(name, prefix) {
|
|
return "virtual/logical device prefix"
|
|
}
|
|
}
|
|
|
|
transport := strings.ToLower(strings.TrimSpace(device.Tran))
|
|
if transport == "virtio" {
|
|
return "virtio transport"
|
|
}
|
|
|
|
subsystems := strings.ToLower(strings.TrimSpace(device.Subsystems))
|
|
for _, token := range linuxSMARTVirtualSubsystemTokens {
|
|
if strings.Contains(subsystems, token) {
|
|
return "virtual/logical subsystem"
|
|
}
|
|
}
|
|
|
|
metadata := strings.ToLower(strings.TrimSpace(device.Vendor + " " + device.Model))
|
|
for _, token := range linuxSMARTVirtualMetadataTokens {
|
|
if strings.Contains(metadata, token) {
|
|
return "virtual disk model/vendor signature"
|
|
}
|
|
}
|
|
|
|
return ""
|
|
}
|
|
|
|
// listBlockDevicesFreeBSD uses sysctl kern.disks and /dev fallback to find disks on FreeBSD.
|
|
func listBlockDevicesFreeBSD(ctx context.Context, diskExclude []string) ([]string, error) {
|
|
names, sysctlErr := freeBSDDiskNamesFromSysctl(ctx)
|
|
if sysctlErr != nil {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Err(sysctlErr).
|
|
Msg("Failed to enumerate FreeBSD disks from kern.disks")
|
|
}
|
|
|
|
fallbackNames, fallbackErr := freeBSDDiskNamesFromDev()
|
|
if fallbackErr != nil {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Err(fallbackErr).
|
|
Msg("Failed to enumerate FreeBSD disks from /dev")
|
|
}
|
|
|
|
if len(names) == 0 {
|
|
names = fallbackNames
|
|
} else if len(fallbackNames) > 0 {
|
|
seen := make(map[string]struct{}, len(names))
|
|
for _, name := range names {
|
|
seen[name] = struct{}{}
|
|
}
|
|
for _, name := range fallbackNames {
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
}
|
|
|
|
if len(names) == 0 {
|
|
switch {
|
|
case sysctlErr != nil:
|
|
return nil, sysctlErr
|
|
case fallbackErr != nil:
|
|
return nil, fallbackErr
|
|
default:
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
var devices []string
|
|
for _, name := range names {
|
|
devicePath := "/dev/" + name
|
|
if matchesDeviceExclude(name, devicePath, diskExclude) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "skip_excluded_device").
|
|
Str("device", devicePath).
|
|
Msg("Skipping excluded device for SMART collection")
|
|
continue
|
|
}
|
|
devices = append(devices, devicePath)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
func freeBSDDiskNamesFromSysctl(ctx context.Context) ([]string, error) {
|
|
output, err := smartRunCommandOutput(ctx, "sysctl", "-n", "kern.disks")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("run sysctl kern.disks: %w", err)
|
|
}
|
|
|
|
var devices []string
|
|
seen := make(map[string]struct{})
|
|
for _, name := range strings.Fields(strings.TrimSpace(string(output))) {
|
|
if name == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[name]; ok {
|
|
continue
|
|
}
|
|
seen[name] = struct{}{}
|
|
devices = append(devices, name)
|
|
}
|
|
|
|
return devices, nil
|
|
}
|
|
|
|
func freeBSDDiskNamesFromDev() ([]string, error) {
|
|
entries, err := readDir("/dev")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var names []string
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name())
|
|
if !isFreeBSDDiskDeviceName(name) {
|
|
continue
|
|
}
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
|
|
return names, nil
|
|
}
|
|
|
|
func isFreeBSDDiskDeviceName(name string) bool {
|
|
for _, prefix := range []string{
|
|
"ad",
|
|
"ada",
|
|
"aacd",
|
|
"amrd",
|
|
"da",
|
|
"idad",
|
|
"ipsd",
|
|
"mfid",
|
|
"mfisyspd",
|
|
"mlxd",
|
|
"mmcsd",
|
|
"nda",
|
|
"nvd",
|
|
"nvme",
|
|
"twa",
|
|
"twed",
|
|
"tws",
|
|
"vtbd",
|
|
"xbd",
|
|
} {
|
|
if hasNumericSuffix(name, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// refineLinuxBlockDeviceIdentity rewrites a freshly collected SMART reading so
|
|
// that its device identity and size reflect the underlying block device rather
|
|
// than the smartctl scan target. smartctl --scan reports NVMe disks by their
|
|
// controller char device (/dev/nvme0), but the stable, user-visible identity is
|
|
// the namespace block device (/dev/nvme0n1) — the same name Proxmox's disks/list
|
|
// and /sys/block expose. It also backfills the capacity from /sys/block, the
|
|
// authoritative size source, so the agent no longer depends on a fragile
|
|
// filesystem-usage match on the server side.
|
|
func refineLinuxBlockDeviceIdentity(smart *DiskSMART, target smartctlTarget) {
|
|
if smart == nil || runtimeGOOS != "linux" {
|
|
return
|
|
}
|
|
block := canonicalBlockDeviceForScanPath(target.Path)
|
|
if block == "" {
|
|
return
|
|
}
|
|
// Disks addressed behind a multiplexing controller (megaraid,7; cciss,1;
|
|
// areca,1/1; ...) all share a single /dev path, so the smartctl scan label is
|
|
// the only thing that disambiguates them and /sys/block describes the array,
|
|
// not the member. Leave those as-is and trust the smartctl-reported capacity.
|
|
if isMultiplexedDeviceType(target.DeviceType) {
|
|
smart.Controller = block
|
|
smart.Target = strings.TrimSpace(target.DeviceType)
|
|
ensureControllerCollectionStatus(smart, "smartctl_scan")
|
|
return
|
|
}
|
|
smart.Device = block
|
|
smart.Controller, smart.Target = linuxBlockDeviceTopology(block)
|
|
ensureControllerCollectionStatus(smart, "sysfs")
|
|
// Unraid's native transport is authoritative for array members. Otherwise
|
|
// smartctl labels SAS members with the generic SCSI protocol when the
|
|
// transport descriptor is absent, so prefer explicit sysfs evidence.
|
|
if native := normalizeSMARTTransport(target.NativeTransport); native != "" {
|
|
smart.Type = native
|
|
} else if smart.Type == "" || smart.Type == "scsi" {
|
|
if evidence := linuxBlockDeviceTransportEvidence(block); evidence != "" {
|
|
smart.Type = evidence
|
|
}
|
|
}
|
|
if smart.Model == "" {
|
|
smart.Model = readTrimmedFile(filepath.Join("/sys/block", block, "device", "model"))
|
|
}
|
|
if smart.Serial == "" {
|
|
smart.Serial = readTrimmedFile(filepath.Join("/sys/block", block, "device", "serial"))
|
|
if smart.Serial != "" {
|
|
if smart.Collection == nil {
|
|
smart.Collection = &diskinventory.CollectionStatus{}
|
|
}
|
|
smart.Collection.Serial = diskinventory.Available("sysfs")
|
|
}
|
|
}
|
|
if smart.WWN == "" {
|
|
smart.WWN = linuxBlockDeviceWWID(block)
|
|
}
|
|
if size := blockDeviceSizeBytes(block); size > 0 {
|
|
smart.SizeBytes = size
|
|
}
|
|
}
|
|
|
|
func ensureControllerCollectionStatus(smart *DiskSMART, source string) {
|
|
if smart.Collection == nil {
|
|
smart.Collection = &diskinventory.CollectionStatus{}
|
|
}
|
|
if smart.Controller != "" || smart.Target != "" {
|
|
smart.Collection.Controller = diskinventory.Available(source)
|
|
return
|
|
}
|
|
smart.Collection.Controller = diskinventory.Missing(source, "controller association was not reported")
|
|
}
|
|
|
|
// linuxBlockDeviceTopology derives a stable controller association and SCSI
|
|
// target from the resolved /sys/block device path. The controller prefers the
|
|
// PCI address immediately preceding hostN; the target is the terminal H:C:T:L
|
|
// segment. Neither value is fabricated when sysfs does not expose it.
|
|
func linuxBlockDeviceTopology(block string) (string, string) {
|
|
resolved, err := smartctlEvalSymlinks(filepath.Join("/sys/block", block, "device"))
|
|
if err != nil {
|
|
return "", ""
|
|
}
|
|
parts := strings.Split(filepath.Clean(resolved), string(filepath.Separator))
|
|
controller := ""
|
|
controllerFallback := ""
|
|
target := ""
|
|
for index, part := range parts {
|
|
if pciControllerAddressRE.MatchString(part) {
|
|
controller = part
|
|
}
|
|
if strings.HasPrefix(part, "host") && hasNumericSuffix(part, "host") && index > 0 {
|
|
controllerFallback = parts[index-1]
|
|
}
|
|
if isSCSITargetAddress(part) {
|
|
target = part
|
|
}
|
|
}
|
|
if controller == "" {
|
|
controller = controllerFallback
|
|
}
|
|
return controller, target
|
|
}
|
|
|
|
func isSCSITargetAddress(value string) bool {
|
|
parts := strings.Split(value, ":")
|
|
if len(parts) != 4 {
|
|
return false
|
|
}
|
|
for _, part := range parts {
|
|
if part == "" || !isAllDigits(part) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// linuxBlockDeviceTransportEvidence returns the transport type only when
|
|
// sysfs states it explicitly, and empty when the kernel supplies no evidence.
|
|
func linuxBlockDeviceTransportEvidence(block string) string {
|
|
for _, candidate := range []string{
|
|
readTrimmedFile(filepath.Join("/sys/block", block, "device", "protocol")),
|
|
readTrimmedFile(filepath.Join("/sys/block", block, "device", "transport")),
|
|
} {
|
|
switch normalized := strings.ToLower(strings.TrimSpace(candidate)); {
|
|
case strings.Contains(normalized, "nvme"):
|
|
return "nvme"
|
|
case strings.Contains(normalized, "sas"):
|
|
return "sas"
|
|
case strings.Contains(normalized, "sata"), strings.Contains(normalized, "ata"):
|
|
return "sata"
|
|
case strings.Contains(normalized, "usb"):
|
|
return "usb"
|
|
}
|
|
}
|
|
resolvedTransport := ""
|
|
if resolved, err := smartctlEvalSymlinks(filepath.Join("/sys/block", block, "device")); err == nil {
|
|
normalized := strings.ToLower(filepath.ToSlash(resolved))
|
|
switch {
|
|
case strings.Contains(normalized, "/usb"):
|
|
return "usb"
|
|
case strings.Contains(normalized, "/ata"):
|
|
resolvedTransport = "sata"
|
|
}
|
|
}
|
|
// Vendor "ATA" is the SCSI layer's marker for an ATA device reached through
|
|
// a SAT translation layer, so it is the more specific signal and must be
|
|
// tested first. A SATA disk behind an LSI/mpt3sas HBA (the common Unraid
|
|
// and TrueNAS layout) exposes sas_address on its scsi_device while still
|
|
// reporting vendor ATA; checking sas_address first classified those as SAS,
|
|
// which drops the -d sat probe hint and steers them back to the -d scsi
|
|
// probe. A genuine SAS disk reports its own vendor, never "ATA".
|
|
if strings.EqualFold(readTrimmedFile(filepath.Join("/sys/block", block, "device", "vendor")), "ATA") {
|
|
return "sata"
|
|
}
|
|
if readTrimmedFile(filepath.Join("/sys/block", block, "device", "sas_address")) != "" {
|
|
return "sas"
|
|
}
|
|
if resolvedTransport != "" {
|
|
return resolvedTransport
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func linuxBlockDeviceTransport(block string, target smartctlTarget) string {
|
|
if native := normalizeSMARTTransport(target.NativeTransport); native != "" {
|
|
return native
|
|
}
|
|
if evidence := linuxBlockDeviceTransportEvidence(block); evidence != "" {
|
|
return evidence
|
|
}
|
|
if strings.HasPrefix(block, "nvme") {
|
|
return "nvme"
|
|
}
|
|
deviceType := strings.ToLower(strings.TrimSpace(target.DeviceType))
|
|
switch {
|
|
case strings.HasPrefix(deviceType, "nvme"):
|
|
return "nvme"
|
|
case strings.HasPrefix(deviceType, "sat"):
|
|
return "sata"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func linuxBlockDeviceWWID(block string) string {
|
|
for _, candidate := range []string{
|
|
filepath.Join("/sys/block", block, "device", "wwid"),
|
|
filepath.Join("/sys/block", block, "wwid"),
|
|
filepath.Join("/sys/block", block, "device", "wwn"),
|
|
} {
|
|
if value := readTrimmedFile(candidate); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// linuxIdentityOnlyDisks builds identity-only entries for physical disks whose
|
|
// SMART probes produced nothing usable. A real disk that refuses SMART must
|
|
// still be listed — Proxmox's own disks/list shows it, and a monitoring view
|
|
// that silently hides a present disk reads as data loss (#1483: a SATA SSD
|
|
// vanished from the UI because its probe yielded no data). Each entry carries
|
|
// only the identity /sys/block can prove (name, capacity, model, serial) plus
|
|
// health UNKNOWN; no SMART data is fabricated. Multiplexed controller paths
|
|
// are skipped: their per-member typed targets describe the real disks, and the
|
|
// shared /dev path is the array, not a disk.
|
|
func linuxIdentityOnlyDisks(missed []smartctlTarget, collected, multiplexed map[string]struct{}, diskExclude []string) []DiskSMART {
|
|
if runtimeGOOS != "linux" || len(missed) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var results []DiskSMART
|
|
seen := make(map[string]struct{}, len(missed))
|
|
for _, target := range missed {
|
|
if isMultiplexedDeviceType(target.DeviceType) {
|
|
continue
|
|
}
|
|
block := canonicalBlockDeviceForScanPath(target.Path)
|
|
if block == "" {
|
|
continue
|
|
}
|
|
if _, ok := collected[block]; ok {
|
|
continue
|
|
}
|
|
if _, ok := multiplexed[block]; ok {
|
|
continue
|
|
}
|
|
if _, ok := seen[block]; ok {
|
|
continue
|
|
}
|
|
seen[block] = struct{}{}
|
|
|
|
size := blockDeviceSizeBytes(block)
|
|
if size <= 0 {
|
|
// Zero capacity means no medium (card readers, empty bridges) or
|
|
// no /sys/block entry at all; nothing real to report.
|
|
continue
|
|
}
|
|
if matchesDeviceExclude(block, "/dev/"+block, diskExclude) {
|
|
continue
|
|
}
|
|
|
|
serial := readTrimmedFile(filepath.Join("/sys/block", block, "device", "serial"))
|
|
controller, controllerTarget := linuxBlockDeviceTopology(block)
|
|
collection := &diskinventory.CollectionStatus{
|
|
Temperature: diskinventory.Unavailable("smartctl", "SMART probe returned no usable temperature data"),
|
|
}
|
|
if serial != "" {
|
|
collection.Serial = diskinventory.Available("sysfs")
|
|
} else {
|
|
collection.Serial = diskinventory.Missing("sysfs", "disk serial was not reported")
|
|
}
|
|
if controller != "" || controllerTarget != "" {
|
|
collection.Controller = diskinventory.Available("sysfs")
|
|
} else {
|
|
collection.Controller = diskinventory.Missing("sysfs", "controller association was not reported")
|
|
}
|
|
results = append(results, DiskSMART{
|
|
Device: block,
|
|
Model: readTrimmedFile(filepath.Join("/sys/block", block, "device", "model")),
|
|
Serial: serial,
|
|
WWN: linuxBlockDeviceWWID(block),
|
|
Type: linuxBlockDeviceTransport(block, target),
|
|
Controller: controller,
|
|
Target: controllerTarget,
|
|
SizeBytes: size,
|
|
Health: "UNKNOWN",
|
|
Collection: collection,
|
|
LastUpdated: timeNow(),
|
|
})
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "identity_only_disk").
|
|
Str("device", block).
|
|
Int64("sizeBytes", size).
|
|
Msg("Reporting identity-only entry for physical disk without usable SMART data")
|
|
}
|
|
return results
|
|
}
|
|
|
|
// isMultiplexedDeviceType reports whether a smartctl -d type addresses a member
|
|
// disk behind a controller (for example "megaraid,7", "areca,1/1", or
|
|
// "sssraid,0,1"), as opposed to a directly attached device.
|
|
func isMultiplexedDeviceType(deviceType string) bool {
|
|
idx := strings.IndexByte(deviceType, ',')
|
|
if idx < 0 || idx+1 >= len(deviceType) {
|
|
return false
|
|
}
|
|
next := deviceType[idx+1]
|
|
return next >= '0' && next <= '9'
|
|
}
|
|
|
|
// canonicalBlockDeviceForScanPath maps a smartctl scan target to its canonical
|
|
// /sys/block device name. NVMe controllers (nvmeN) resolve to their first
|
|
// namespace (nvmeNnM); every other device keeps its basename.
|
|
func canonicalBlockDeviceForScanPath(scanPath string) string {
|
|
name := path.Base(strings.TrimSpace(scanPath))
|
|
if name == "" || name == "." || name == "/" {
|
|
return ""
|
|
}
|
|
if isNVMeControllerName(name) {
|
|
if ns := firstNVMeNamespace(name); ns != "" {
|
|
return ns
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
|
|
// isNVMeControllerName reports whether name is an NVMe controller char device
|
|
// (e.g. "nvme0") rather than a namespace block device (e.g. "nvme0n1").
|
|
func isNVMeControllerName(name string) bool {
|
|
return hasNumericSuffix(name, "nvme")
|
|
}
|
|
|
|
// firstNVMeNamespace returns the lowest-numbered namespace block device for an
|
|
// NVMe controller (e.g. "nvme0" -> "nvme0n1"), or "" when none is found.
|
|
func firstNVMeNamespace(controller string) string {
|
|
entries, err := readDir("/sys/block")
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
prefix := controller + "n"
|
|
best := ""
|
|
for _, entry := range entries {
|
|
name := strings.TrimSpace(entry.Name())
|
|
if !strings.HasPrefix(name, prefix) {
|
|
continue
|
|
}
|
|
// Require a pure namespace (nvme0n1), not a partition (nvme0n1p1).
|
|
if suffix := name[len(prefix):]; suffix == "" || !isAllDigits(suffix) {
|
|
continue
|
|
}
|
|
number, err := strconv.Atoi(name[len(prefix):])
|
|
if err != nil {
|
|
continue
|
|
}
|
|
bestNumber := 0
|
|
if best != "" {
|
|
bestNumber, _ = strconv.Atoi(best[len(prefix):])
|
|
}
|
|
if best == "" || number < bestNumber {
|
|
best = name
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// blockDeviceSizeBytes reads /sys/block/<name>/size, which the kernel always
|
|
// reports in 512-byte sectors regardless of the physical block size.
|
|
func blockDeviceSizeBytes(name string) int64 {
|
|
if name == "" {
|
|
return 0
|
|
}
|
|
data, err := smartctlReadFile(filepath.Join("/sys/block", name, "size"))
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
sectors, err := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64)
|
|
if err != nil || sectors <= 0 {
|
|
return 0
|
|
}
|
|
return sectors * 512
|
|
}
|
|
|
|
func isAllDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, r := range s {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func hasNumericSuffix(name, prefix string) bool {
|
|
if !strings.HasPrefix(name, prefix) || len(name) == len(prefix) {
|
|
return false
|
|
}
|
|
|
|
for _, r := range name[len(prefix):] {
|
|
if r < '0' || r > '9' {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// matchesDeviceExclude checks if a block device matches any exclusion pattern.
|
|
// Patterns can match against the device name (e.g., "sda", "nvme0n1") or the full
|
|
// path (e.g., "/dev/sda"). Supports:
|
|
// - Exact match: "sda" matches device named "sda"
|
|
// - Prefix pattern (ending with *): "nvme*" matches "nvme0n1", "nvme1n1", etc.
|
|
// - Contains pattern (surrounded by *): "*cache*" matches any device with "cache" in name
|
|
func matchesDeviceExclude(name, devicePath string, excludePatterns []string) bool {
|
|
if len(excludePatterns) == 0 {
|
|
return false
|
|
}
|
|
|
|
for _, pattern := range excludePatterns {
|
|
pattern = strings.TrimSpace(pattern)
|
|
if pattern == "" {
|
|
continue
|
|
}
|
|
|
|
if strings.HasPrefix(pattern, "*") && strings.HasSuffix(pattern, "*") && len(pattern) > 2 {
|
|
substring := pattern[1 : len(pattern)-1]
|
|
if strings.Contains(name, substring) || strings.Contains(devicePath, substring) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
|
|
if strings.HasSuffix(pattern, "*") {
|
|
prefix := pattern[:len(pattern)-1]
|
|
if strings.HasPrefix(name, prefix) || strings.HasPrefix(devicePath, prefix) {
|
|
return true
|
|
}
|
|
continue
|
|
}
|
|
|
|
if name == pattern || devicePath == pattern {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// collectDeviceSMART runs smartctl on a single device and parses the result.
|
|
func collectDeviceSMART(ctx context.Context, device string) (*DiskSMART, error) {
|
|
return collectSMARTTarget(ctx, smartctlTarget{Path: device})
|
|
}
|
|
|
|
func collectSMARTTarget(ctx context.Context, target smartctlTarget) (*DiskSMART, error) {
|
|
cmdCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
defer cancel()
|
|
|
|
smartctlPath, err := resolveSmartctlPath()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("look up smartctl binary: %w", err)
|
|
}
|
|
|
|
attempts := smartctlProbeAttempts(target)
|
|
var firstParsed *DiskSMART
|
|
var firstStandby *DiskSMART
|
|
var lastErr error
|
|
|
|
for i, args := range attempts {
|
|
output, err := runSmartctlCompatible(cmdCtx, smartctlPath, args)
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if errors.As(err, &exitErr) {
|
|
exitCode := exitErr.ExitCode()
|
|
if exitCode == smartctlStandbyExitStatus &&
|
|
len(output) == 0 &&
|
|
smartctlArgsUseStandbyExitStatus(args) {
|
|
standbyResult := &DiskSMART{
|
|
Device: filepath.Base(target.Path),
|
|
Standby: true,
|
|
Collection: &diskinventory.CollectionStatus{
|
|
Serial: diskinventory.Unavailable("smartctl", "disk is in standby"),
|
|
Temperature: diskinventory.Unavailable("smartctl", "disk is in standby"),
|
|
},
|
|
LastUpdated: timeNow(),
|
|
}
|
|
if runtimeGOOS == "freebsd" && i < len(attempts)-1 && target.DeviceType == "" {
|
|
if firstStandby == nil {
|
|
firstStandby = standbyResult
|
|
}
|
|
continue
|
|
}
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "device_in_standby").
|
|
Str("device", filepath.Base(target.Path)).
|
|
Msg("Skipping SMART collection for standby device")
|
|
return standbyResult, nil
|
|
}
|
|
if len(output) == 0 {
|
|
lastErr = fmt.Errorf("run smartctl for %s: %w", target.Path, err)
|
|
continue
|
|
}
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "collect_device_smart_nonzero_exit").
|
|
Str("device", filepath.Base(target.Path)).
|
|
Int("exit_code", exitCode).
|
|
Msg("smartctl returned non-zero exit status with JSON output")
|
|
} else {
|
|
lastErr = fmt.Errorf("run smartctl for %s: %w", target.Path, err)
|
|
continue
|
|
}
|
|
}
|
|
|
|
result, parseErr := parseSMARTOutput(output, target)
|
|
if parseErr != nil {
|
|
lastErr = parseErr
|
|
continue
|
|
}
|
|
result = enrichFreeBSDSCTTemperature(cmdCtx, smartctlPath, args, target, result)
|
|
if firstParsed == nil {
|
|
firstParsed = result
|
|
} else {
|
|
firstParsed = mergeSMARTAttemptEvidence(firstParsed, result)
|
|
}
|
|
if !shouldRetrySMARTTarget(target.Path, result, i, len(attempts)) {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "collect_device_smart_success").
|
|
Str("device", result.Device).
|
|
Str("type", result.Type).
|
|
Str("model", result.Model).
|
|
Int("temperature", result.Temperature).
|
|
Str("health", result.Health).
|
|
Msg("collected SMART data")
|
|
return firstParsed, nil
|
|
}
|
|
}
|
|
|
|
if firstParsed != nil {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "collect_device_smart_success").
|
|
Str("device", firstParsed.Device).
|
|
Str("type", firstParsed.Type).
|
|
Str("model", firstParsed.Model).
|
|
Int("temperature", firstParsed.Temperature).
|
|
Str("health", firstParsed.Health).
|
|
Msg("collected SMART data")
|
|
return firstParsed, nil
|
|
}
|
|
if firstStandby != nil {
|
|
log.Debug().
|
|
Str("component", smartctlComponent).
|
|
Str("action", "device_in_standby").
|
|
Str("device", filepath.Base(target.Path)).
|
|
Msg("Skipping SMART collection for standby device")
|
|
return firstStandby, nil
|
|
}
|
|
if lastErr != nil {
|
|
return nil, lastErr
|
|
}
|
|
return nil, errSMARTDataUnavailable
|
|
}
|
|
|
|
func resolveSmartctlPath() (string, error) {
|
|
if configured := strings.TrimSpace(os.Getenv("PULSE_SMARTCTL_PATH")); configured != "" {
|
|
if !filepath.IsAbs(configured) {
|
|
return "", fmt.Errorf("PULSE_SMARTCTL_PATH must be an absolute path")
|
|
}
|
|
return configured, nil
|
|
}
|
|
return execLookPath("smartctl")
|
|
}
|
|
|
|
// runSmartctlCompatible keeps JSON output on supported smartmontools releases,
|
|
// but retries in text mode for older vendor builds (notably DSM's 6.5 build)
|
|
// that reject --json=o before examining the device.
|
|
func runSmartctlCompatible(ctx context.Context, smartctlPath string, args []string) ([]byte, error) {
|
|
output, err := smartRunCommandOutput(ctx, smartctlPath, args...)
|
|
if err == nil || !smartctlRejectsJSONOption(output, err) {
|
|
return output, err
|
|
}
|
|
|
|
legacyArgs := make([]string, 0, len(args))
|
|
for _, arg := range args {
|
|
if arg != "--json=o" {
|
|
legacyArgs = append(legacyArgs, arg)
|
|
}
|
|
}
|
|
return smartRunCommandOutput(ctx, smartctlPath, legacyArgs...)
|
|
}
|
|
|
|
func smartctlRejectsJSONOption(output []byte, err error) bool {
|
|
message := strings.ToLower(strings.TrimSpace(string(output) + " " + errorString(err)))
|
|
if !strings.Contains(message, "json") {
|
|
return false
|
|
}
|
|
for _, marker := range []string{
|
|
"unrecognized option",
|
|
"unknown option",
|
|
"invalid option",
|
|
"unrecognized command line option",
|
|
} {
|
|
if strings.Contains(message, marker) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func errorString(err error) string {
|
|
if err == nil {
|
|
return ""
|
|
}
|
|
return err.Error()
|
|
}
|
|
|
|
func smartctlArgsUseStandbyExitStatus(args []string) bool {
|
|
want := "standby," + strconv.Itoa(smartctlStandbyExitStatus)
|
|
for index := 0; index+1 < len(args); index++ {
|
|
if args[index] == "-n" && args[index+1] == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func mergeSMARTAttemptEvidence(base, incoming *DiskSMART) *DiskSMART {
|
|
if base == nil {
|
|
return incoming
|
|
}
|
|
if incoming == nil {
|
|
return base
|
|
}
|
|
if base.Model == "" {
|
|
base.Model = incoming.Model
|
|
}
|
|
if base.Serial == "" {
|
|
base.Serial = incoming.Serial
|
|
}
|
|
if base.WWN == "" {
|
|
base.WWN = incoming.WWN
|
|
}
|
|
if base.SizeBytes <= 0 {
|
|
base.SizeBytes = incoming.SizeBytes
|
|
}
|
|
if base.Type == "" || base.Type == "scsi" {
|
|
if incoming.Type != "" {
|
|
base.Type = incoming.Type
|
|
}
|
|
}
|
|
if base.Controller == "" {
|
|
base.Controller = incoming.Controller
|
|
}
|
|
if base.Target == "" {
|
|
base.Target = incoming.Target
|
|
}
|
|
if base.Temperature <= 0 && incoming.Temperature > 0 {
|
|
base.Temperature = incoming.Temperature
|
|
}
|
|
if shouldReplaceSMARTAttemptHealth(base.Health, incoming.Health) {
|
|
base.Health = incoming.Health
|
|
}
|
|
base.Attributes = mergeSMARTAttributes(base.Attributes, incoming.Attributes)
|
|
base.Collection = diskinventory.MergeStatus(base.Collection, incoming.Collection)
|
|
return base
|
|
}
|
|
|
|
func shouldReplaceSMARTAttemptHealth(existing, incoming string) bool {
|
|
incoming = strings.ToUpper(strings.TrimSpace(incoming))
|
|
if incoming == "" || incoming == "UNKNOWN" {
|
|
return false
|
|
}
|
|
existing = strings.ToUpper(strings.TrimSpace(existing))
|
|
if incoming == "FAILED" {
|
|
return true
|
|
}
|
|
return existing == "" || existing == "UNKNOWN"
|
|
}
|
|
|
|
func mergeSMARTAttributes(base, incoming *SMARTAttributes) *SMARTAttributes {
|
|
if base == nil {
|
|
return incoming
|
|
}
|
|
if incoming == nil {
|
|
return base
|
|
}
|
|
if base.PowerOnHours == nil {
|
|
base.PowerOnHours = incoming.PowerOnHours
|
|
}
|
|
if base.PowerCycles == nil {
|
|
base.PowerCycles = incoming.PowerCycles
|
|
}
|
|
if base.ReallocatedSectors == nil {
|
|
base.ReallocatedSectors = incoming.ReallocatedSectors
|
|
}
|
|
if base.PendingSectors == nil {
|
|
base.PendingSectors = incoming.PendingSectors
|
|
}
|
|
if base.OfflineUncorrectable == nil {
|
|
base.OfflineUncorrectable = incoming.OfflineUncorrectable
|
|
}
|
|
if base.UDMACRCErrors == nil {
|
|
base.UDMACRCErrors = incoming.UDMACRCErrors
|
|
}
|
|
if base.PercentageUsed == nil {
|
|
base.PercentageUsed = incoming.PercentageUsed
|
|
}
|
|
if base.AvailableSpare == nil {
|
|
base.AvailableSpare = incoming.AvailableSpare
|
|
}
|
|
if base.MediaErrors == nil {
|
|
base.MediaErrors = incoming.MediaErrors
|
|
}
|
|
if base.UnsafeShutdowns == nil {
|
|
base.UnsafeShutdowns = incoming.UnsafeShutdowns
|
|
}
|
|
return base
|
|
}
|
|
|
|
func smartctlProbeAttempts(target smartctlTarget) [][]string {
|
|
device := target.Path
|
|
if target.DeviceType != "" {
|
|
deviceTypes := []string{}
|
|
if smartctlDeviceTypeMatchesTransport(target.DeviceType, linuxSMARTTargetTransport(target)) {
|
|
deviceTypes = append(deviceTypes, target.DeviceType)
|
|
}
|
|
// A scan device type is a hint, not ground truth: smartctl can
|
|
// suggest a type whose full query (-i -A -H) fails or returns no usable
|
|
// data even though untyped auto-detection works (#1483: a SATA SSD
|
|
// dropped after its typed probe yielded nothing). Retry untyped before
|
|
// giving up. Multiplexed controller members are exempt because dropping
|
|
// the -d would re-probe the shared array device, not the member.
|
|
if runtimeGOOS == "linux" && !isMultiplexedDeviceType(target.DeviceType) {
|
|
deviceTypes = append(deviceTypes, "")
|
|
deviceTypes = append(deviceTypes, linuxInferredSmartctlDeviceTypes(target)...)
|
|
}
|
|
return smartctlArgsForDeviceTypes(device, deviceTypes)
|
|
}
|
|
|
|
if runtimeGOOS == "linux" {
|
|
deviceTypes := append([]string{""}, linuxInferredSmartctlDeviceTypes(target)...)
|
|
return smartctlArgsForDeviceTypes(device, deviceTypes)
|
|
}
|
|
|
|
if runtimeGOOS == "freebsd" {
|
|
deviceTypes := freeBSDSmartctlDeviceTypes(filepath.Base(device))
|
|
if len(deviceTypes) > 0 {
|
|
return smartctlArgsForDeviceTypes(device, append(deviceTypes, ""))
|
|
}
|
|
}
|
|
|
|
return [][]string{
|
|
smartctlArgs(device, ""),
|
|
}
|
|
}
|
|
|
|
func smartctlArgsForDeviceTypes(device string, deviceTypes []string) [][]string {
|
|
attempts := make([][]string, 0, len(deviceTypes))
|
|
seen := make(map[string]struct{}, len(deviceTypes))
|
|
for _, deviceType := range deviceTypes {
|
|
deviceType = strings.TrimSpace(deviceType)
|
|
if _, ok := seen[deviceType]; ok {
|
|
continue
|
|
}
|
|
seen[deviceType] = struct{}{}
|
|
attempts = append(attempts, smartctlArgs(device, deviceType))
|
|
}
|
|
return attempts
|
|
}
|
|
|
|
func linuxInferredSmartctlDeviceTypes(target smartctlTarget) []string {
|
|
if runtimeGOOS != "linux" {
|
|
return nil
|
|
}
|
|
name := strings.ToLower(filepath.Base(strings.TrimSpace(target.Path)))
|
|
if !linuxDirectSATDeviceRE.MatchString(name) {
|
|
return nil
|
|
}
|
|
switch linuxSMARTTargetTransport(target) {
|
|
case "sata":
|
|
return []string{"sat"}
|
|
case "sas":
|
|
return []string{"scsi"}
|
|
case "usb":
|
|
return nil
|
|
default:
|
|
// An untyped sdX target is more commonly direct ATA than SAS. The SAT
|
|
// retry recovers omitted SATA scan targets (#1483), but deliberately
|
|
// never guesses SCSI: forcing -d scsi on libata can issue an unsupported
|
|
// REPORT SUPPORTED OPERATION CODES request (#1612).
|
|
return []string{"sat"}
|
|
}
|
|
}
|
|
|
|
func linuxSMARTTargetTransport(target smartctlTarget) string {
|
|
if native := normalizeSMARTTransport(target.NativeTransport); native != "" {
|
|
return native
|
|
}
|
|
block := canonicalBlockDeviceForScanPath(target.Path)
|
|
if block == "" {
|
|
return ""
|
|
}
|
|
return linuxBlockDeviceTransportEvidence(block)
|
|
}
|
|
|
|
func smartctlDeviceTypeMatchesTransport(deviceType, transport string) bool {
|
|
deviceType = strings.ToLower(strings.TrimSpace(deviceType))
|
|
transport = normalizeSMARTTransport(transport)
|
|
switch transport {
|
|
case "sata":
|
|
return !strings.HasPrefix(deviceType, "scsi")
|
|
case "sas":
|
|
return !strings.HasPrefix(deviceType, "sat")
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func smartctlArgs(device, deviceType string) []string {
|
|
args := []string{}
|
|
if deviceType != "" {
|
|
args = append(args, "-d", deviceType)
|
|
}
|
|
// The standby guard exists to avoid spinning up sleeping rotational disks.
|
|
// An SSD has nothing to spin up, and some SATA SSDs answer the guard's
|
|
// CHECK POWER MODE with a bogus standby state that permanently hides their
|
|
// SMART data (#1516), so confirmed non-rotational devices are probed
|
|
// without it. Multiplexed controller members keep the guard: the shared
|
|
// /dev path's rotational flag describes the array device, not the member.
|
|
if isMultiplexedDeviceType(deviceType) || !linuxNonRotationalBlockDevice(device) {
|
|
args = append(args, "-n", "standby,"+strconv.Itoa(smartctlStandbyExitStatus))
|
|
}
|
|
args = append(args, "-i", "-A", "-H", "--json=o", device)
|
|
return args
|
|
}
|
|
|
|
// linuxNonRotationalBlockDevice reports whether the canonical block device
|
|
// behind path is positively confirmed non-rotational (SSD) via sysfs. Any
|
|
// uncertainty — non-Linux, unresolvable device, unreadable sysfs — returns
|
|
// false so the caller keeps the conservative standby guard.
|
|
func linuxNonRotationalBlockDevice(device string) bool {
|
|
if runtimeGOOS != "linux" {
|
|
return false
|
|
}
|
|
block := canonicalBlockDeviceForScanPath(device)
|
|
if block == "" {
|
|
return false
|
|
}
|
|
return readTrimmedFile(path.Join("/sys/block", block, "queue", "rotational")) == "0"
|
|
}
|
|
|
|
func smartctlArgsWithLog(args []string, logPage string) []string {
|
|
if logPage == "" || len(args) == 0 {
|
|
return append([]string(nil), args...)
|
|
}
|
|
for i := 0; i < len(args)-1; i++ {
|
|
if args[i] == "-l" && args[i+1] == logPage {
|
|
return append([]string(nil), args...)
|
|
}
|
|
}
|
|
|
|
deviceIndex := len(args) - 1
|
|
withLog := make([]string, 0, len(args)+2)
|
|
withLog = append(withLog, args[:deviceIndex]...)
|
|
withLog = append(withLog, "-l", logPage)
|
|
withLog = append(withLog, args[deviceIndex:]...)
|
|
return withLog
|
|
}
|
|
|
|
func freeBSDSmartctlDeviceTypes(device string) []string {
|
|
if runtimeGOOS != "freebsd" {
|
|
return nil
|
|
}
|
|
|
|
switch {
|
|
case strings.HasPrefix(device, "ada"), strings.HasPrefix(device, "ad"):
|
|
return []string{"sat"}
|
|
case strings.HasPrefix(device, "da"):
|
|
return []string{"sat,auto", "scsi"}
|
|
case strings.HasPrefix(device, "nda"), strings.HasPrefix(device, "nvd"), strings.HasPrefix(device, "nvme"):
|
|
return []string{"nvme"}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func shouldRetrySMARTTarget(device string, result *DiskSMART, attemptIndex, attemptCount int) bool {
|
|
if attemptIndex >= attemptCount-1 || result == nil {
|
|
return false
|
|
}
|
|
if result.Temperature > 0 {
|
|
return false
|
|
}
|
|
if result.Standby {
|
|
return true
|
|
}
|
|
switch runtimeGOOS {
|
|
case "freebsd":
|
|
return len(freeBSDSmartctlDeviceTypes(filepath.Base(device))) > 0
|
|
case "linux":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func enrichFreeBSDSCTTemperature(ctx context.Context, smartctlPath string, args []string, target smartctlTarget, current *DiskSMART) *DiskSMART {
|
|
if runtimeGOOS != "freebsd" || current == nil || current.Standby || current.Temperature > 0 {
|
|
return current
|
|
}
|
|
if len(freeBSDSmartctlDeviceTypes(filepath.Base(target.Path))) == 0 {
|
|
return current
|
|
}
|
|
|
|
sctArgs := smartctlArgsWithLog(args, "scttempsts")
|
|
if len(sctArgs) == len(args) {
|
|
return current
|
|
}
|
|
|
|
output, err := smartRunCommandOutput(ctx, smartctlPath, sctArgs...)
|
|
if err != nil {
|
|
var exitErr *exec.ExitError
|
|
if !errors.As(err, &exitErr) || len(output) == 0 {
|
|
return current
|
|
}
|
|
}
|
|
|
|
sctResult, parseErr := parseSMARTOutput(output, target)
|
|
if parseErr != nil || sctResult == nil || sctResult.Temperature <= 0 {
|
|
return current
|
|
}
|
|
return sctResult
|
|
}
|
|
|
|
func parseSMARTOutput(output []byte, target smartctlTarget) (*DiskSMART, error) {
|
|
var smartData smartctlJSON
|
|
if err := json.Unmarshal(output, &smartData); err != nil {
|
|
return parseSMARTTextOutput(string(output), target)
|
|
}
|
|
|
|
result := &DiskSMART{
|
|
Device: target.displayName(),
|
|
Model: smartData.ModelName,
|
|
Serial: smartData.SerialNumber,
|
|
Type: detectDiskType(smartData),
|
|
Standby: isStandbyPowerMode(smartData.PowerMode),
|
|
LastUpdated: timeNow(),
|
|
Collection: &diskinventory.CollectionStatus{},
|
|
}
|
|
|
|
if smartData.WWN.NAA != 0 {
|
|
result.WWN = formatWWN(smartData.WWN.NAA, smartData.WWN.OUI, smartData.WWN.ID)
|
|
}
|
|
|
|
// Capacity straight from the device smartctl just queried. On Linux this is
|
|
// refined to the authoritative /sys/block value in CollectSMARTLocal; here it
|
|
// is the cross-platform fallback so non-Linux hosts still report a size.
|
|
if smartData.NVMeTotalCapacity > 0 {
|
|
result.SizeBytes = smartData.NVMeTotalCapacity
|
|
} else if smartData.UserCapacity.Bytes > 0 {
|
|
result.SizeBytes = smartData.UserCapacity.Bytes
|
|
}
|
|
|
|
if validSMARTTemperature(smartData.Temperature.Current) {
|
|
result.Temperature = smartData.Temperature.Current
|
|
} else if smartData.NVMeSmartHealthInformationLog != nil && validSMARTTemperature(smartData.NVMeSmartHealthInformationLog.Temperature) {
|
|
result.Temperature = smartData.NVMeSmartHealthInformationLog.Temperature
|
|
} else if validSMARTTemperature(smartData.ATASCTStatus.Current.Value) {
|
|
result.Temperature = smartData.ATASCTStatus.Current.Value
|
|
} else {
|
|
for _, attributeID := range []int{194, 190} {
|
|
for _, attr := range smartData.ATASmartAttributes.Table {
|
|
if attr.ID != attributeID {
|
|
continue
|
|
}
|
|
temp := parseRawValue(attr.Raw.String, attr.Raw.Value)
|
|
if validSMARTTemperature(int(temp)) {
|
|
result.Temperature = int(temp)
|
|
break
|
|
}
|
|
}
|
|
if result.Temperature > 0 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if smartData.SmartStatus != nil {
|
|
if smartData.SmartStatus.Passed {
|
|
result.Health = "PASSED"
|
|
} else {
|
|
result.Health = "FAILED"
|
|
}
|
|
}
|
|
|
|
applySMARTTextFallback(result, parseSMARTTextFallback(strings.Join(smartData.Smartctl.Output, "\n")))
|
|
if result.Serial != "" {
|
|
result.Collection.Serial = diskinventory.Available("smartctl")
|
|
} else {
|
|
result.Collection.Serial = diskinventory.Missing("smartctl", "disk serial was not reported")
|
|
}
|
|
switch {
|
|
case result.Temperature > 0:
|
|
result.Collection.Temperature = diskinventory.Available("smartctl")
|
|
case result.Standby:
|
|
result.Collection.Temperature = diskinventory.Unavailable("smartctl", "disk is in standby")
|
|
default:
|
|
result.Collection.Temperature = diskinventory.Unsupported("smartctl", "device did not expose a temperature reading")
|
|
}
|
|
if result.Health == "" {
|
|
result.Health = "UNKNOWN"
|
|
}
|
|
|
|
result.Attributes = parseSMARTAttributes(&smartData, result.Type)
|
|
if result.Health == "UNKNOWN" && result.Temperature == 0 && result.Attributes == nil && !result.Standby {
|
|
return nil, errSMARTDataUnavailable
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func validSMARTTemperature(value int) bool {
|
|
return value > 0 && value < 150
|
|
}
|
|
|
|
func parseSMARTTextOutput(text string, target smartctlTarget) (*DiskSMART, error) {
|
|
fallback := parseSMARTTextFallback(text)
|
|
result := &DiskSMART{
|
|
Device: target.displayName(),
|
|
Model: fallback.Model,
|
|
Serial: fallback.Serial,
|
|
Type: fallback.Type,
|
|
Temperature: fallback.Temperature,
|
|
Health: fallback.Health,
|
|
Standby: fallback.Standby,
|
|
LastUpdated: timeNow(),
|
|
Collection: &diskinventory.CollectionStatus{},
|
|
}
|
|
if result.Type == "" {
|
|
switch {
|
|
case target.DeviceType == "nvme",
|
|
strings.HasPrefix(filepath.Base(target.Path), "nvme"),
|
|
strings.HasPrefix(filepath.Base(target.Path), "nvd"),
|
|
strings.HasPrefix(filepath.Base(target.Path), "nda"):
|
|
result.Type = "nvme"
|
|
default:
|
|
result.Type = "sata"
|
|
}
|
|
}
|
|
if result.Health == "" {
|
|
result.Health = "UNKNOWN"
|
|
}
|
|
if result.Serial != "" {
|
|
result.Collection.Serial = diskinventory.Available("smartctl_text")
|
|
} else {
|
|
result.Collection.Serial = diskinventory.Missing("smartctl_text", "disk serial was not reported")
|
|
}
|
|
switch {
|
|
case result.Temperature > 0:
|
|
result.Collection.Temperature = diskinventory.Available("smartctl_text")
|
|
case result.Standby:
|
|
result.Collection.Temperature = diskinventory.Unavailable("smartctl_text", "disk is in standby")
|
|
default:
|
|
result.Collection.Temperature = diskinventory.Unsupported("smartctl_text", "device did not expose a temperature reading")
|
|
}
|
|
if result.Health == "UNKNOWN" && result.Temperature == 0 && !result.Standby {
|
|
return nil, errSMARTDataUnavailable
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func applySMARTTextFallback(result *DiskSMART, fallback smartTextFallback) {
|
|
if result == nil {
|
|
return
|
|
}
|
|
if result.Model == "" && fallback.Model != "" {
|
|
result.Model = fallback.Model
|
|
}
|
|
if result.Serial == "" && fallback.Serial != "" {
|
|
result.Serial = fallback.Serial
|
|
}
|
|
if (result.Type == "" || result.Type == "scsi") && fallback.Type != "" {
|
|
result.Type = fallback.Type
|
|
}
|
|
if result.Health == "" && fallback.Health != "" {
|
|
result.Health = fallback.Health
|
|
}
|
|
if result.Temperature == 0 && fallback.Temperature > 0 {
|
|
result.Temperature = fallback.Temperature
|
|
}
|
|
if !result.Standby && fallback.Standby {
|
|
result.Standby = true
|
|
}
|
|
}
|
|
|
|
func parseSMARTTextFallback(text string) smartTextFallback {
|
|
var fallback smartTextFallback
|
|
for _, rawLine := range strings.Split(text, "\n") {
|
|
line := strings.TrimSpace(rawLine)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(line)
|
|
switch {
|
|
case strings.HasPrefix(lower, "device model:"):
|
|
fallback.Model = strings.TrimSpace(line[len("Device Model:"):])
|
|
case strings.HasPrefix(lower, "model number:"):
|
|
if fallback.Model == "" {
|
|
fallback.Model = strings.TrimSpace(line[len("Model Number:"):])
|
|
}
|
|
case strings.HasPrefix(lower, "product:"):
|
|
if fallback.Model == "" {
|
|
fallback.Model = strings.TrimSpace(line[len("Product:"):])
|
|
}
|
|
case strings.HasPrefix(lower, "serial number:"):
|
|
fallback.Serial = strings.TrimSpace(line[len("Serial Number:"):])
|
|
case strings.Contains(lower, "device is in standby mode"),
|
|
strings.Contains(lower, "standby (os)"):
|
|
fallback.Standby = true
|
|
case strings.HasPrefix(lower, "smart overall-health self-assessment test result:"):
|
|
fallback.Health = parseSMARTHealthText(line)
|
|
case strings.HasPrefix(lower, "smart health status:"):
|
|
if fallback.Health == "" {
|
|
fallback.Health = parseSMARTHealthText(line)
|
|
}
|
|
case strings.Contains(lower, "transport protocol:") && strings.Contains(lower, "nvme"):
|
|
fallback.Type = "nvme"
|
|
case strings.Contains(lower, "transport protocol:") && strings.Contains(lower, "sas"):
|
|
fallback.Type = "sas"
|
|
case strings.Contains(lower, "sata version is:") || strings.Contains(lower, "ata version is:"):
|
|
if fallback.Type == "" {
|
|
fallback.Type = "sata"
|
|
}
|
|
}
|
|
|
|
if fallback.Temperature == 0 {
|
|
if matches := smartTextCurrentTempRE.FindStringSubmatch(line); len(matches) == 2 {
|
|
if temp, err := strconv.Atoi(matches[1]); err == nil && temp > 0 && temp < 150 {
|
|
fallback.Temperature = temp
|
|
continue
|
|
}
|
|
}
|
|
if matches := smartTextTemperatureRE.FindStringSubmatch(line); len(matches) == 2 && strings.Contains(lower, "celsius") {
|
|
if temp, err := strconv.Atoi(matches[1]); err == nil && temp > 0 && temp < 150 {
|
|
fallback.Temperature = temp
|
|
continue
|
|
}
|
|
}
|
|
if matches := smartTextTempAttributeRE.FindStringSubmatch(line); len(matches) == 3 {
|
|
if temp, err := strconv.Atoi(matches[2]); err == nil && temp > 0 && temp < 150 {
|
|
fallback.Temperature = temp
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return fallback
|
|
}
|
|
|
|
func parseSMARTHealthText(line string) string {
|
|
lower := strings.ToLower(line)
|
|
switch {
|
|
case strings.Contains(lower, "passed"), strings.Contains(lower, "ok"):
|
|
return "PASSED"
|
|
case strings.Contains(lower, "failed"):
|
|
return "FAILED"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func isStandbyPowerMode(powerMode string) bool {
|
|
mode := strings.ToLower(strings.TrimSpace(powerMode))
|
|
return strings.Contains(mode, "standby") || strings.Contains(mode, "sleep")
|
|
}
|
|
|
|
// parseSMARTAttributes extracts normalized SMART attributes from smartctl JSON output.
|
|
func parseSMARTAttributes(data *smartctlJSON, diskType string) *SMARTAttributes {
|
|
attrs := &SMARTAttributes{}
|
|
hasData := false
|
|
|
|
if diskType == "nvme" {
|
|
if data.NVMeSmartHealthInformationLog != nil {
|
|
nvmeLog := data.NVMeSmartHealthInformationLog
|
|
if nvmeLog.PowerOnHours != nil {
|
|
hasData = true
|
|
value := *nvmeLog.PowerOnHours
|
|
attrs.PowerOnHours = &value
|
|
}
|
|
if nvmeLog.PowerCycles != nil {
|
|
hasData = true
|
|
value := *nvmeLog.PowerCycles
|
|
attrs.PowerCycles = &value
|
|
}
|
|
if nvmeLog.PercentageUsed != nil {
|
|
hasData = true
|
|
value := *nvmeLog.PercentageUsed
|
|
attrs.PercentageUsed = &value
|
|
}
|
|
if nvmeLog.AvailableSpare != nil {
|
|
hasData = true
|
|
value := *nvmeLog.AvailableSpare
|
|
attrs.AvailableSpare = &value
|
|
}
|
|
if nvmeLog.MediaErrors != nil {
|
|
hasData = true
|
|
value := *nvmeLog.MediaErrors
|
|
attrs.MediaErrors = &value
|
|
}
|
|
if nvmeLog.UnsafeShutdowns != nil {
|
|
hasData = true
|
|
value := *nvmeLog.UnsafeShutdowns
|
|
attrs.UnsafeShutdowns = &value
|
|
}
|
|
}
|
|
} else if diskType == "sas" || diskType == "scsi" {
|
|
// SCSI drives report no ATA attribute table; their counters live in
|
|
// dedicated log pages smartctl surfaces as top-level JSON fields.
|
|
if data.PowerOnTime != nil {
|
|
hasData = true
|
|
poh := data.PowerOnTime.Hours
|
|
attrs.PowerOnHours = &poh
|
|
}
|
|
if data.SCSIGrownDefectList != nil {
|
|
hasData = true
|
|
defects := *data.SCSIGrownDefectList
|
|
attrs.ReallocatedSectors = &defects
|
|
}
|
|
if data.SCSIPercentageUsedEnduranceIndicator != nil {
|
|
hasData = true
|
|
used := *data.SCSIPercentageUsedEnduranceIndicator
|
|
attrs.PercentageUsed = &used
|
|
}
|
|
} else {
|
|
for _, attr := range data.ATASmartAttributes.Table {
|
|
hasData = true
|
|
raw := parseRawValue(attr.Raw.String, attr.Raw.Value)
|
|
switch attr.ID {
|
|
case 5:
|
|
v := raw
|
|
attrs.ReallocatedSectors = &v
|
|
case 9:
|
|
v := raw
|
|
attrs.PowerOnHours = &v
|
|
case 12:
|
|
v := raw
|
|
attrs.PowerCycles = &v
|
|
case 197:
|
|
v := raw
|
|
attrs.PendingSectors = &v
|
|
case 198:
|
|
v := raw
|
|
attrs.OfflineUncorrectable = &v
|
|
case 199:
|
|
v := raw
|
|
attrs.UDMACRCErrors = &v
|
|
}
|
|
}
|
|
}
|
|
|
|
if !hasData {
|
|
return nil
|
|
}
|
|
return attrs
|
|
}
|
|
|
|
// parseRawValue extracts the primary integer from a SMART attribute's raw string.
|
|
// Some drives (notably Seagate) pack vendor-specific data in the upper bytes of
|
|
// the 48-bit raw value, making raw.value unreliable. For example, Power_On_Hours
|
|
// may report raw.value=150323855943 while raw.string="16951 (223 173 0)" where
|
|
// only 16951 is the actual hours. Falls back to rawValue if string parsing fails.
|
|
func parseRawValue(rawString string, rawValue int64) int64 {
|
|
s := strings.TrimSpace(rawString)
|
|
if s == "" {
|
|
return rawValue
|
|
}
|
|
end := 0
|
|
for end < len(s) && s[end] >= '0' && s[end] <= '9' {
|
|
end++
|
|
}
|
|
if end == 0 {
|
|
return rawValue
|
|
}
|
|
v, err := strconv.ParseInt(s[:end], 10, 64)
|
|
if err != nil {
|
|
return rawValue
|
|
}
|
|
return v
|
|
}
|
|
|
|
// detectDiskType determines the disk transport type from smartctl output.
|
|
// SAS drives report device protocol "SCSI"; their SAS transport is only
|
|
// visible in the SCSI transport descriptor. An empty return means smartctl
|
|
// gave no transport evidence, so the text-output and sysfs refinements decide
|
|
// before the legacy sata default applies.
|
|
func detectDiskType(data smartctlJSON) string {
|
|
protocol := strings.ToLower(data.Device.Protocol)
|
|
transport := strings.ToLower(data.SCSITransportProtocol.Name)
|
|
switch {
|
|
case strings.Contains(protocol, "nvme"):
|
|
return "nvme"
|
|
case strings.Contains(protocol, "sas"):
|
|
return "sas"
|
|
case strings.Contains(protocol, "scsi"):
|
|
if strings.Contains(transport, "sas") {
|
|
return "sas"
|
|
}
|
|
return "scsi"
|
|
case strings.Contains(protocol, "ata"):
|
|
return "sata"
|
|
default:
|
|
devType := strings.ToLower(data.Device.Type)
|
|
switch {
|
|
case strings.Contains(devType, "nvme"):
|
|
return "nvme"
|
|
case strings.Contains(devType, "scsi"):
|
|
if strings.Contains(transport, "sas") {
|
|
return "sas"
|
|
}
|
|
return "scsi"
|
|
case strings.HasPrefix(devType, "sat"):
|
|
return "sata"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatWWN formats WWN components into a standard string.
|
|
func formatWWN(naa, oui, id uint64) string {
|
|
return strconv.FormatUint(naa, 16) + "-" +
|
|
strconv.FormatUint(oui, 16) + "-" +
|
|
strconv.FormatUint(id, 16)
|
|
}
|
|
|
|
func runCommandOutputLimited(ctx context.Context, maxBytes int, name string, args ...string) ([]byte, error) {
|
|
if maxBytes <= 0 {
|
|
return nil, fmt.Errorf("max bytes must be positive")
|
|
}
|
|
|
|
cmd := exec.CommandContext(ctx, name, args...)
|
|
var stderr limitedCommandBuffer
|
|
stderr.limit = 32 * 1024
|
|
cmd.Stderr = &stderr
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
output := make([]byte, 0, 4096)
|
|
buf := make([]byte, 32*1024)
|
|
exceeded := false
|
|
|
|
for {
|
|
n, readErr := stdout.Read(buf)
|
|
if n > 0 {
|
|
remaining := maxBytes - len(output)
|
|
if remaining > 0 {
|
|
if n <= remaining {
|
|
output = append(output, buf[:n]...)
|
|
} else {
|
|
output = append(output, buf[:remaining]...)
|
|
exceeded = true
|
|
}
|
|
} else {
|
|
exceeded = true
|
|
}
|
|
|
|
if exceeded && cmd.Process != nil {
|
|
_ = cmd.Process.Kill()
|
|
}
|
|
}
|
|
|
|
if readErr == io.EOF {
|
|
break
|
|
}
|
|
if readErr != nil {
|
|
_ = cmd.Wait()
|
|
return output, readErr
|
|
}
|
|
}
|
|
|
|
waitErr := cmd.Wait()
|
|
if exceeded {
|
|
return nil, fmt.Errorf("%w (%d bytes)", errCommandOutputTooLarge, maxBytes)
|
|
}
|
|
if waitErr != nil {
|
|
if message := strings.TrimSpace(stderr.String()); message != "" {
|
|
return output, fmt.Errorf("%w: %s", waitErr, message)
|
|
}
|
|
return output, waitErr
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
type limitedCommandBuffer struct {
|
|
bytes.Buffer
|
|
limit int
|
|
}
|
|
|
|
func (b *limitedCommandBuffer) Write(p []byte) (int, error) {
|
|
originalLength := len(p)
|
|
if remaining := b.limit - b.Len(); remaining > 0 {
|
|
if len(p) > remaining {
|
|
p = p[:remaining]
|
|
}
|
|
_, _ = b.Buffer.Write(p)
|
|
}
|
|
return originalLength, nil
|
|
}
|