Go service (nasmon/) plus nfpm packaging (packaging/), carried over from the home_nas monorepo. Adds rpm packaging alongside the existing deb build (Fedora/RHEL/Rocky dependency names via nfpm overrides, per-format systemd unit path, dpkg+rpm-compatible maintainer scripts) and a Gitea Actions workflow that builds both formats on every push. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
292 lines
9.0 KiB
Go
292 lines
9.0 KiB
Go
package collect
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type SmartAttr struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Value int `json:"value"`
|
|
Worst int `json:"worst"`
|
|
Thresh int `json:"thresh"`
|
|
Raw int64 `json:"raw"`
|
|
RawString string `json:"raw_string"`
|
|
WhenFailed string `json:"when_failed"`
|
|
}
|
|
|
|
type NVMeHealth struct {
|
|
CriticalWarning int `json:"critical_warning"`
|
|
AvailableSpare int `json:"available_spare"`
|
|
SpareThreshold int `json:"spare_threshold"`
|
|
PercentageUsed int `json:"percentage_used"`
|
|
MediaErrors int64 `json:"media_errors"`
|
|
ErrLogEntries int64 `json:"err_log_entries"`
|
|
UnsafeShutdowns int64 `json:"unsafe_shutdowns"`
|
|
}
|
|
|
|
type SelfTest struct {
|
|
Type string `json:"type"`
|
|
Status string `json:"status"`
|
|
Passed bool `json:"passed"`
|
|
Hours int64 `json:"hours"`
|
|
InProgress bool `json:"in_progress"`
|
|
Remaining int `json:"remaining_pct"`
|
|
}
|
|
|
|
// Smart is the normalised view of one smartctl -j -a run.
|
|
type Smart struct {
|
|
Device string `json:"device"`
|
|
Protocol string `json:"protocol"` // ATA | NVMe | SCSI
|
|
Model string `json:"model"`
|
|
Serial string `json:"serial"`
|
|
Firmware string `json:"firmware"`
|
|
Capacity uint64 `json:"capacity"`
|
|
Standby bool `json:"standby"` // disk asleep, nothing was read
|
|
Supported bool `json:"supported"` // SMART available & enabled
|
|
Healthy bool `json:"healthy"`
|
|
TempC int `json:"temp_c"`
|
|
HasTemp bool `json:"has_temp"`
|
|
PowerOnHrs int64 `json:"power_on_hours"`
|
|
PowerCycles int64 `json:"power_cycles"`
|
|
Attrs []SmartAttr `json:"attrs,omitempty"`
|
|
NVMe *NVMeHealth `json:"nvme,omitempty"`
|
|
LastTest *SelfTest `json:"last_test,omitempty"`
|
|
Error string `json:"error,omitempty"`
|
|
CheckedAt time.Time `json:"checked_at"`
|
|
}
|
|
|
|
// Attr returns the ATA attribute with the given id, if present.
|
|
func (s *Smart) Attr(id int) (SmartAttr, bool) {
|
|
for _, a := range s.Attrs {
|
|
if a.ID == id {
|
|
return a, true
|
|
}
|
|
}
|
|
return SmartAttr{}, false
|
|
}
|
|
|
|
// ReadSmart runs smartctl for one device. skipStandby adds "-n standby" so a
|
|
// spun-down disk is not woken; then Standby=true and nothing else is filled.
|
|
func ReadSmart(dev string, skipStandby bool) Smart {
|
|
args := []string{"-j", "-a"}
|
|
if skipStandby {
|
|
args = append(args, "-n", "standby")
|
|
}
|
|
args = append(args, dev)
|
|
out, err := run(60*time.Second, "smartctl", args...)
|
|
s := parseSmart(out)
|
|
s.Device = dev
|
|
s.CheckedAt = time.Now()
|
|
if len(out) == 0 && err != nil {
|
|
s.Error = err.Error()
|
|
}
|
|
return s
|
|
}
|
|
|
|
type smartMsg struct {
|
|
String string `json:"string"`
|
|
Severity string `json:"severity"`
|
|
}
|
|
|
|
type smartJSON struct {
|
|
Smartctl struct {
|
|
ExitStatus int `json:"exit_status"`
|
|
Messages []smartMsg `json:"messages"`
|
|
} `json:"smartctl"`
|
|
Device struct {
|
|
Protocol string `json:"protocol"`
|
|
} `json:"device"`
|
|
ModelName string `json:"model_name"`
|
|
SerialNumber string `json:"serial_number"`
|
|
Firmware string `json:"firmware_version"`
|
|
UserCapacity struct {
|
|
Bytes uint64 `json:"bytes"`
|
|
} `json:"user_capacity"`
|
|
SmartSupport struct {
|
|
Available bool `json:"available"`
|
|
Enabled bool `json:"enabled"`
|
|
} `json:"smart_support"`
|
|
SmartStatus *struct {
|
|
Passed bool `json:"passed"`
|
|
} `json:"smart_status"`
|
|
Temperature struct {
|
|
Current *int `json:"current"`
|
|
} `json:"temperature"`
|
|
PowerOnTime struct {
|
|
Hours int64 `json:"hours"`
|
|
} `json:"power_on_time"`
|
|
PowerCycleCount int64 `json:"power_cycle_count"`
|
|
ATAAttrs struct {
|
|
Table []struct {
|
|
ID int `json:"id"`
|
|
Name string `json:"name"`
|
|
Value int `json:"value"`
|
|
Worst int `json:"worst"`
|
|
Thresh int `json:"thresh"`
|
|
WhenFailed string `json:"when_failed"`
|
|
Raw struct {
|
|
Value int64 `json:"value"`
|
|
String string `json:"string"`
|
|
} `json:"raw"`
|
|
} `json:"table"`
|
|
} `json:"ata_smart_attributes"`
|
|
ATASelfTestLog struct {
|
|
Standard struct {
|
|
Table []struct {
|
|
Type struct {
|
|
String string `json:"string"`
|
|
} `json:"type"`
|
|
Status struct {
|
|
String string `json:"string"`
|
|
Passed bool `json:"passed"`
|
|
} `json:"status"`
|
|
LifetimeHours int64 `json:"lifetime_hours"`
|
|
} `json:"table"`
|
|
} `json:"standard"`
|
|
} `json:"ata_smart_self_test_log"`
|
|
ATASmartData struct {
|
|
SelfTest struct {
|
|
Status struct {
|
|
String string `json:"string"`
|
|
RemainingPercent *int `json:"remaining_percent"`
|
|
} `json:"status"`
|
|
} `json:"self_test"`
|
|
} `json:"ata_smart_data"`
|
|
NVMeLog *struct {
|
|
CriticalWarning int `json:"critical_warning"`
|
|
AvailableSpare int `json:"available_spare"`
|
|
SpareThreshold int `json:"available_spare_threshold"`
|
|
PercentageUsed int `json:"percentage_used"`
|
|
MediaErrors int64 `json:"media_errors"`
|
|
NumErrLogEntries int64 `json:"num_err_log_entries"`
|
|
UnsafeShutdowns int64 `json:"unsafe_shutdowns"`
|
|
PowerOnHours int64 `json:"power_on_hours"`
|
|
PowerCycles int64 `json:"power_cycles"`
|
|
} `json:"nvme_smart_health_information_log"`
|
|
NVMeSelfTestLog struct {
|
|
Current struct {
|
|
Value int `json:"value"`
|
|
String string `json:"string"`
|
|
} `json:"current_self_test_operation"`
|
|
CurrentCompletion *int `json:"current_self_test_completion_percent"`
|
|
Table []struct {
|
|
Code struct {
|
|
String string `json:"string"`
|
|
} `json:"self_test_code"`
|
|
Result struct {
|
|
Value int `json:"value"`
|
|
String string `json:"string"`
|
|
} `json:"self_test_result"`
|
|
PowerOnHours int64 `json:"power_on_hours"`
|
|
} `json:"table"`
|
|
} `json:"nvme_self_test_log"`
|
|
}
|
|
|
|
func parseSmart(b []byte) Smart {
|
|
var s Smart
|
|
var j smartJSON
|
|
if err := json.Unmarshal(b, &j); err != nil {
|
|
s.Error = fmt.Sprintf("parse smartctl output: %v", err)
|
|
return s
|
|
}
|
|
for _, m := range j.Smartctl.Messages {
|
|
if strings.Contains(strings.ToUpper(m.String), "STANDBY") {
|
|
s.Standby = true
|
|
return s
|
|
}
|
|
}
|
|
// bit 0: command line error, bit 1: device open failed
|
|
if j.Smartctl.ExitStatus&3 != 0 {
|
|
for _, m := range j.Smartctl.Messages {
|
|
if m.Severity == "error" {
|
|
s.Error = m.String
|
|
break
|
|
}
|
|
}
|
|
if s.Error == "" {
|
|
s.Error = fmt.Sprintf("smartctl exit status %d", j.Smartctl.ExitStatus)
|
|
}
|
|
}
|
|
s.Protocol = j.Device.Protocol
|
|
s.Model = j.ModelName
|
|
s.Serial = j.SerialNumber
|
|
s.Firmware = j.Firmware
|
|
s.Capacity = j.UserCapacity.Bytes
|
|
s.Supported = (j.SmartSupport.Available && j.SmartSupport.Enabled) || j.SmartStatus != nil
|
|
if j.SmartStatus != nil {
|
|
s.Healthy = j.SmartStatus.Passed
|
|
}
|
|
if j.Temperature.Current != nil {
|
|
s.TempC, s.HasTemp = *j.Temperature.Current, true
|
|
}
|
|
s.PowerOnHrs = j.PowerOnTime.Hours
|
|
s.PowerCycles = j.PowerCycleCount
|
|
for _, a := range j.ATAAttrs.Table {
|
|
raw := a.Raw.Value
|
|
if a.ID == 188 {
|
|
// Seagate packs three 16-bit counters into 188; the low word is the timeout count
|
|
raw &= 0xFFFF
|
|
}
|
|
s.Attrs = append(s.Attrs, SmartAttr{
|
|
ID: a.ID, Name: a.Name, Value: a.Value, Worst: a.Worst, Thresh: a.Thresh,
|
|
Raw: raw, RawString: a.Raw.String, WhenFailed: a.WhenFailed,
|
|
})
|
|
}
|
|
if j.NVMeLog != nil {
|
|
s.NVMe = &NVMeHealth{
|
|
CriticalWarning: j.NVMeLog.CriticalWarning,
|
|
AvailableSpare: j.NVMeLog.AvailableSpare,
|
|
SpareThreshold: j.NVMeLog.SpareThreshold,
|
|
PercentageUsed: j.NVMeLog.PercentageUsed,
|
|
MediaErrors: j.NVMeLog.MediaErrors,
|
|
ErrLogEntries: j.NVMeLog.NumErrLogEntries,
|
|
UnsafeShutdowns: j.NVMeLog.UnsafeShutdowns,
|
|
}
|
|
if s.PowerOnHrs == 0 {
|
|
s.PowerOnHrs = j.NVMeLog.PowerOnHours
|
|
}
|
|
if s.PowerCycles == 0 {
|
|
s.PowerCycles = j.NVMeLog.PowerCycles
|
|
}
|
|
}
|
|
// self-test: an in-progress test wins, otherwise the newest log entry
|
|
if rp := j.ATASmartData.SelfTest.Status.RemainingPercent; rp != nil {
|
|
s.LastTest = &SelfTest{Status: j.ATASmartData.SelfTest.Status.String, InProgress: true, Remaining: *rp, Passed: true}
|
|
} else if t := j.ATASelfTestLog.Standard.Table; len(t) > 0 {
|
|
s.LastTest = &SelfTest{Type: t[0].Type.String, Status: t[0].Status.String, Passed: t[0].Status.Passed, Hours: t[0].LifetimeHours}
|
|
} else if j.NVMeSelfTestLog.Current.Value != 0 {
|
|
rem := 0
|
|
if j.NVMeSelfTestLog.CurrentCompletion != nil {
|
|
rem = 100 - *j.NVMeSelfTestLog.CurrentCompletion
|
|
}
|
|
s.LastTest = &SelfTest{Status: j.NVMeSelfTestLog.Current.String, InProgress: true, Remaining: rem, Passed: true}
|
|
} else if t := j.NVMeSelfTestLog.Table; len(t) > 0 {
|
|
s.LastTest = &SelfTest{Type: t[0].Code.String, Status: t[0].Result.String, Passed: t[0].Result.Value == 0, Hours: t[0].PowerOnHours}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// StartSelfTest launches a short or long SMART self-test on the device.
|
|
func StartSelfTest(dev, kind string) error {
|
|
if kind != "short" && kind != "long" {
|
|
return fmt.Errorf("unknown self-test kind %q", kind)
|
|
}
|
|
out, err := run(30*time.Second, "smartctl", "-j", "-t", kind, dev)
|
|
if err != nil {
|
|
var j smartJSON
|
|
if json.Unmarshal(out, &j) == nil {
|
|
for _, m := range j.Smartctl.Messages {
|
|
if m.Severity == "error" {
|
|
return fmt.Errorf("smartctl -t %s %s: %s", kind, dev, m.String)
|
|
}
|
|
}
|
|
}
|
|
return fmt.Errorf("smartctl -t %s %s: %w", kind, dev, err)
|
|
}
|
|
return nil
|
|
}
|