Files
rcourtman a39935182d Parse smartctl 7.5 power_mode object on guarded probes
smartmontools 7.5 emits power_mode as an {ata_value, name} object whenever
the -n guard runs CHECK POWER MODE, which is every rotational-disk probe.
The parser declared the field as a string, so json.Unmarshal failed for the
whole document and healthy spinning disks degraded to the lossy text
fallback, surfacing as no usable SMART data while guard-free SSD probes
kept working. That is the exact rotational-only failure split in the
discussion 1690 debug log (SAS3224 HBA, smartctl 7.5). Decode both shapes
the field has used, key standby on the reported name, and never fail the
document over this field. Also broaden the text fallback standby match to
the EPC names (STANDBY_Y, STANDBY (OS), SLEEP) that the mode-suffixed
match missed.

Refs #1690

Contract-Neutral: smartctl 7.5 power_mode JSON parse fix, internal decoder only, no DiskSMART payload or subsystem contract delta (discussion #1690)
2026-08-13 10:25:28 +01:00

2381 lines
71 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 smartctlPowerModeJSON `json:"power_mode"`
}
// smartctlPowerModeJSON absorbs every shape smartctl has used for the JSON
// power_mode field. smartmontools 7.5 introduced it as an object,
// {"ata_value": 255, "name": "ACTIVE or IDLE"}, emitted whenever the -n
// guard runs CHECK POWER MODE — which is every rotational-disk probe here.
// Decoding it into a plain string failed json.Unmarshal for the whole
// document, so healthy output from spinning disks degraded to the lossy text
// fallback and was dropped as "no usable SMART data" (discussion #1690). The
// decoder never propagates an error: an unrecognized future shape must cost
// only this field, never the document.
type smartctlPowerModeJSON struct {
Name string `json:"name"`
ATAValue int `json:"ata_value"`
}
func (p *smartctlPowerModeJSON) UnmarshalJSON(data []byte) error {
var object struct {
Name string `json:"name"`
ATAValue int `json:"ata_value"`
}
if err := json.Unmarshal(data, &object); err == nil {
p.Name = object.Name
p.ATAValue = object.ATAValue
return nil
}
var name string
if err := json.Unmarshal(data, &name); err == nil {
p.Name = name
}
return nil
}
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.Name),
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)
// Range-check in int64 before narrowing: parseRawValue
// returns a full 64-bit value and int is 32 bits on the
// 386/arm builds, so converting first would let a raw
// value such as 1<<32+20 truncate into the valid band.
if validSMARTTemperature64(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 validSMARTTemperature64(int64(value))
}
// validSMARTTemperature64 is the authoritative range check. Callers holding a
// 64-bit raw SMART value must use it before narrowing to int, which is only
// 32 bits wide on the 386 and arm release builds.
func validSMARTTemperature64(value int64) 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"),
strings.Contains(lower, "device is in sleep"),
strings.Contains(lower, "standby (os)"):
// "device is in standby" deliberately drops the " mode" suffix:
// smartctl names EPC states STANDBY_Y / STANDBY (OS), and the
// suffixed match silently missed both (discussion #1690).
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
}