Initial import: nasmon health monitor, split out of home_nas
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>
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user