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,75 @@
|
||||
// Package collect reads system state from /proc, /sys and CLI tools.
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type CPU struct {
|
||||
UsagePct float64 `json:"usage_pct"`
|
||||
Cores int `json:"cores"`
|
||||
Load [3]float64 `json:"load"`
|
||||
}
|
||||
|
||||
type cpuTimes struct{ idle, total uint64 }
|
||||
|
||||
// CPUSampler keeps the previous /proc/stat reading to compute usage deltas.
|
||||
type CPUSampler struct {
|
||||
prev cpuTimes
|
||||
init bool
|
||||
}
|
||||
|
||||
func (s *CPUSampler) Sample() (CPU, error) {
|
||||
b, err := os.ReadFile("/proc/stat")
|
||||
if err != nil {
|
||||
return CPU{}, err
|
||||
}
|
||||
cur, err := parseProcStat(b)
|
||||
if err != nil {
|
||||
return CPU{}, err
|
||||
}
|
||||
c := CPU{Cores: runtime.NumCPU()}
|
||||
if s.init && cur.total > s.prev.total {
|
||||
dt := float64(cur.total - s.prev.total)
|
||||
di := float64(cur.idle - s.prev.idle)
|
||||
c.UsagePct = 100 * (1 - di/dt)
|
||||
}
|
||||
s.prev, s.init = cur, true
|
||||
if lb, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
f := strings.Fields(string(lb))
|
||||
for i := 0; i < 3 && i < len(f); i++ {
|
||||
c.Load[i], _ = strconv.ParseFloat(f[i], 64)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// parseProcStat returns idle and total jiffies from the aggregate "cpu" line.
|
||||
func parseProcStat(b []byte) (cpuTimes, error) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
for sc.Scan() {
|
||||
f := strings.Fields(sc.Text())
|
||||
if len(f) < 5 || f[0] != "cpu" {
|
||||
continue
|
||||
}
|
||||
var t cpuTimes
|
||||
for i, v := range f[1:] {
|
||||
n, err := strconv.ParseUint(v, 10, 64)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
t.total += n
|
||||
if i == 3 || i == 4 { // idle + iowait
|
||||
t.idle += n
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
return cpuTimes{}, fmt.Errorf("no cpu line in /proc/stat")
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Disk struct {
|
||||
Name string `json:"name"` // sda
|
||||
Path string `json:"path"` // /dev/sda
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
Size uint64 `json:"size"`
|
||||
Rota bool `json:"rota"`
|
||||
Tran string `json:"tran"` // sata, nvme, usb
|
||||
}
|
||||
|
||||
type lsblkOut struct {
|
||||
Blockdevices []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
Size flexUint `json:"size"`
|
||||
Rota flexBool `json:"rota"`
|
||||
Tran string `json:"tran"`
|
||||
} `json:"blockdevices"`
|
||||
}
|
||||
|
||||
// flexBool accepts true/false as well as the "1"/"0" strings older lsblk prints.
|
||||
type flexBool bool
|
||||
|
||||
func (b *flexBool) UnmarshalJSON(data []byte) error {
|
||||
switch strings.Trim(string(data), `"`) {
|
||||
case "true", "1":
|
||||
*b = true
|
||||
default:
|
||||
*b = false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// flexUint accepts a JSON number or a numeric string.
|
||||
type flexUint uint64
|
||||
|
||||
func (u *flexUint) UnmarshalJSON(data []byte) error {
|
||||
n, err := strconv.ParseUint(strings.Trim(string(data), `"`), 10, 64)
|
||||
if err != nil {
|
||||
*u = 0
|
||||
return nil
|
||||
}
|
||||
*u = flexUint(n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListDisks returns physical block devices (whole disks, no loop/zram/dm).
|
||||
func ListDisks() ([]Disk, error) {
|
||||
b, err := run(10*time.Second, "lsblk", "-J", "-b", "-d", "-o", "NAME,PATH,TYPE,MODEL,SERIAL,SIZE,ROTA,TRAN")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseLsblk(b)
|
||||
}
|
||||
|
||||
func parseLsblk(b []byte) ([]Disk, error) {
|
||||
var o lsblkOut
|
||||
if err := json.Unmarshal(b, &o); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []Disk
|
||||
for _, d := range o.Blockdevices {
|
||||
if d.Type != "disk" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(d.Name, "zram") || strings.HasPrefix(d.Name, "loop") ||
|
||||
strings.HasPrefix(d.Name, "dm-") || strings.HasPrefix(d.Name, "zd") || strings.HasPrefix(d.Name, "sr") {
|
||||
continue
|
||||
}
|
||||
path := d.Path
|
||||
if path == "" {
|
||||
path = "/dev/" + d.Name
|
||||
}
|
||||
out = append(out, Disk{
|
||||
Name: d.Name, Path: path, Model: strings.TrimSpace(d.Model),
|
||||
Serial: strings.TrimSpace(d.Serial), Size: uint64(d.Size), Rota: bool(d.Rota), Tran: d.Tran,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"os/exec"
|
||||
"time"
|
||||
)
|
||||
|
||||
// run executes a command with a timeout and returns stdout even on non-zero
|
||||
// exit (smartctl encodes state in its exit code and still prints JSON).
|
||||
func run(timeout time.Duration, name string, args ...string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
var out, errb bytes.Buffer
|
||||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
var ee *exec.ExitError
|
||||
if errors.As(err, &ee) {
|
||||
return out.Bytes(), ee
|
||||
}
|
||||
return out.Bytes(), errors.New(name + ": " + err.Error() + " " + errb.String())
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//go:build linux
|
||||
|
||||
package collect
|
||||
|
||||
import "syscall"
|
||||
|
||||
type FSUsage struct {
|
||||
Path string `json:"path"`
|
||||
Total uint64 `json:"total"`
|
||||
Free uint64 `json:"free"`
|
||||
}
|
||||
|
||||
func ReadFS(path string) (FSUsage, error) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return FSUsage{}, err
|
||||
}
|
||||
bs := uint64(st.Bsize)
|
||||
return FSUsage{Path: path, Total: st.Blocks * bs, Free: st.Bavail * bs}, nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !linux
|
||||
|
||||
package collect
|
||||
|
||||
import "errors"
|
||||
|
||||
type FSUsage struct {
|
||||
Path string `json:"path"`
|
||||
Total uint64 `json:"total"`
|
||||
Free uint64 `json:"free"`
|
||||
}
|
||||
|
||||
func ReadFS(path string) (FSUsage, error) { return FSUsage{}, errors.New("unsupported OS") }
|
||||
@@ -0,0 +1,77 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Memory struct {
|
||||
Total uint64 `json:"total"`
|
||||
Available uint64 `json:"available"`
|
||||
Used uint64 `json:"used"` // Total - Available
|
||||
SwapTotal uint64 `json:"swap_total"`
|
||||
SwapUsed uint64 `json:"swap_used"`
|
||||
ARCSize uint64 `json:"arc_size"`
|
||||
ARCMax uint64 `json:"arc_max"`
|
||||
}
|
||||
|
||||
func ReadMemory() (Memory, error) {
|
||||
b, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return Memory{}, err
|
||||
}
|
||||
m := parseMeminfo(b)
|
||||
if ab, err := os.ReadFile("/proc/spl/kstat/zfs/arcstats"); err == nil {
|
||||
m.ARCSize, m.ARCMax = parseArcstats(ab)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func parseMeminfo(b []byte) Memory {
|
||||
kv := map[string]uint64{}
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
for sc.Scan() {
|
||||
f := strings.Fields(sc.Text())
|
||||
if len(f) < 2 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseUint(f[1], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
kv[strings.TrimSuffix(f[0], ":")] = n * 1024
|
||||
}
|
||||
m := Memory{
|
||||
Total: kv["MemTotal"],
|
||||
Available: kv["MemAvailable"],
|
||||
SwapTotal: kv["SwapTotal"],
|
||||
}
|
||||
m.Used = m.Total - m.Available
|
||||
m.SwapUsed = m.SwapTotal - kv["SwapFree"]
|
||||
return m
|
||||
}
|
||||
|
||||
// parseArcstats picks "size" and "c_max" from the kstat table (name type data).
|
||||
func parseArcstats(b []byte) (size, max uint64) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
for sc.Scan() {
|
||||
f := strings.Fields(sc.Text())
|
||||
if len(f) != 3 {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseUint(f[2], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
switch f[0] {
|
||||
case "size":
|
||||
size = n
|
||||
case "c_max":
|
||||
max = n
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package collect
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseProcStat(t *testing.T) {
|
||||
in := "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 50 0 25 400 25 0 0 0 0 0\n"
|
||||
c, err := parseProcStat([]byte(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.total != 1000 || c.idle != 850 {
|
||||
t.Errorf("total=%d idle=%d", c.total, c.idle)
|
||||
}
|
||||
if _, err := parseProcStat([]byte("intr 1 2 3\n")); err == nil {
|
||||
t.Errorf("expected error without cpu line")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMeminfo(t *testing.T) {
|
||||
in := "MemTotal: 16000000 kB\nMemFree: 1000000 kB\nMemAvailable: 9000000 kB\nSwapTotal: 2000000 kB\nSwapFree: 1500000 kB\n"
|
||||
m := parseMeminfo([]byte(in))
|
||||
if m.Total != 16000000*1024 || m.Available != 9000000*1024 || m.Used != 7000000*1024 {
|
||||
t.Errorf("mem = %+v", m)
|
||||
}
|
||||
if m.SwapTotal != 2000000*1024 || m.SwapUsed != 500000*1024 {
|
||||
t.Errorf("swap = %+v", m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseArcstats(t *testing.T) {
|
||||
in := "13 1 0x01 123 1 2 3\nname type data\nhits 4 1000\nsize 4 4294967296\nc_max 4 8589934592\n"
|
||||
size, max := parseArcstats([]byte(in))
|
||||
if size != 4294967296 || max != 8589934592 {
|
||||
t.Errorf("size=%d max=%d", size, max)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUTemp(t *testing.T) {
|
||||
temps := []Temp{
|
||||
{Chip: "nvme", Label: "Composite", C: 41},
|
||||
{Chip: "coretemp", Label: "Package id 0", C: 55},
|
||||
{Chip: "coretemp", Label: "Core 0", C: 52},
|
||||
{Chip: "acpitz", Label: "temp1", C: 27},
|
||||
}
|
||||
c, ok := CPUTemp(temps)
|
||||
if !ok || c != 55 {
|
||||
t.Errorf("cpu temp = %v %v", c, ok)
|
||||
}
|
||||
if _, ok := CPUTemp([]Temp{{Chip: "nvme", Label: "Composite", C: 41}}); ok {
|
||||
t.Errorf("nvme must not count as CPU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmbSessions(t *testing.T) {
|
||||
in := `{"timestamp": "x", "version": "4.19", "smb_conf": "/etc/samba/smb.conf", "sessions": {"123": {"username": "a"}, "456": {"username": "b"}}}`
|
||||
if n := parseSmbSessions([]byte(in)); n != 2 {
|
||||
t.Errorf("sessions = %d", n)
|
||||
}
|
||||
if n := parseSmbSessions([]byte("garbage")); n != 0 {
|
||||
t.Errorf("garbage = %d", n)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ShareServices is the read-only view of the file-sharing daemons.
|
||||
type ShareServices struct {
|
||||
SMBInstalled bool `json:"smb_installed"`
|
||||
NFSInstalled bool `json:"nfs_installed"`
|
||||
SMBActive bool `json:"smb_active"`
|
||||
NFSActive bool `json:"nfs_active"`
|
||||
SMBSessions int `json:"smb_sessions"`
|
||||
}
|
||||
|
||||
// ReadShareServices checks which sharing daemons exist and run.
|
||||
func ReadShareServices() ShareServices {
|
||||
var s ShareServices
|
||||
if _, err := exec.LookPath("smbd"); err == nil {
|
||||
s.SMBInstalled = true
|
||||
s.SMBActive = unitActive("smbd")
|
||||
if s.SMBActive {
|
||||
s.SMBSessions = smbSessions()
|
||||
}
|
||||
}
|
||||
if _, err := exec.LookPath("exportfs"); err == nil {
|
||||
s.NFSInstalled = true
|
||||
s.NFSActive = unitActive("nfs-server")
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func unitActive(unit string) bool {
|
||||
out, _ := run(5*time.Second, "systemctl", "is-active", unit)
|
||||
return strings.TrimSpace(string(out)) == "active"
|
||||
}
|
||||
|
||||
// smbSessions counts connected SMB sessions via smbstatus --json (Samba ≥ 4.17).
|
||||
func smbSessions() int {
|
||||
out, err := run(10*time.Second, "smbstatus", "--json", "-b")
|
||||
if err != nil && len(out) == 0 {
|
||||
return 0
|
||||
}
|
||||
return parseSmbSessions(out)
|
||||
}
|
||||
|
||||
func parseSmbSessions(b []byte) int {
|
||||
var j struct {
|
||||
Sessions map[string]json.RawMessage `json:"sessions"`
|
||||
}
|
||||
if json.Unmarshal(b, &j) != nil {
|
||||
return 0
|
||||
}
|
||||
return len(j.Sessions)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package collect
|
||||
|
||||
import "testing"
|
||||
|
||||
const smartATA = `{
|
||||
"smartctl": {"version": [7, 4], "exit_status": 0, "messages": []},
|
||||
"device": {"name": "/dev/sda", "type": "sat", "protocol": "ATA"},
|
||||
"model_name": "WDC WD80EFZZ-68BTXN0",
|
||||
"serial_number": "WD-AAA",
|
||||
"firmware_version": "81.00A81",
|
||||
"user_capacity": {"blocks": 15628053168, "bytes": 8001563222016},
|
||||
"rotation_rate": 5640,
|
||||
"smart_support": {"available": true, "enabled": true},
|
||||
"smart_status": {"passed": true},
|
||||
"ata_smart_data": {
|
||||
"self_test": {"status": {"value": 0, "string": "completed without error", "passed": true}}
|
||||
},
|
||||
"ata_smart_attributes": {"table": [
|
||||
{"id": 5, "name": "Reallocated_Sector_Ct", "value": 200, "worst": 200, "thresh": 140, "when_failed": "", "raw": {"value": 0, "string": "0"}},
|
||||
{"id": 9, "name": "Power_On_Hours", "value": 90, "worst": 90, "thresh": 0, "when_failed": "", "raw": {"value": 7541, "string": "7541"}},
|
||||
{"id": 194, "name": "Temperature_Celsius", "value": 116, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 34, "string": "34"}},
|
||||
{"id": 197, "name": "Current_Pending_Sector", "value": 200, "worst": 200, "thresh": 0, "when_failed": "", "raw": {"value": 8, "string": "8"}},
|
||||
{"id": 199, "name": "UDMA_CRC_Error_Count", "value": 200, "worst": 200, "thresh": 0, "when_failed": "", "raw": {"value": 2, "string": "2"}},
|
||||
{"id": 188, "name": "Command_Timeout", "value": 100, "worst": 100, "thresh": 0, "when_failed": "", "raw": {"value": 196611, "string": "3 3 3"}}
|
||||
]},
|
||||
"power_on_time": {"hours": 7541},
|
||||
"power_cycle_count": 21,
|
||||
"temperature": {"current": 34},
|
||||
"ata_smart_self_test_log": {"standard": {"revision": 1, "table": [
|
||||
{"type": {"value": 2, "string": "Extended offline"}, "status": {"value": 0, "string": "Completed without error", "passed": true}, "lifetime_hours": 7500},
|
||||
{"type": {"value": 1, "string": "Short offline"}, "status": {"value": 0, "string": "Completed without error", "passed": true}, "lifetime_hours": 7400}
|
||||
]}}
|
||||
}`
|
||||
|
||||
const smartNVMe = `{
|
||||
"smartctl": {"version": [7, 4], "exit_status": 0},
|
||||
"device": {"name": "/dev/nvme0", "type": "nvme", "protocol": "NVMe"},
|
||||
"model_name": "Samsung SSD 980 500GB",
|
||||
"serial_number": "S64ANL0T",
|
||||
"firmware_version": "2B4QFXO7",
|
||||
"user_capacity": {"blocks": 976773168, "bytes": 500107862016},
|
||||
"smart_support": {"available": true, "enabled": true},
|
||||
"smart_status": {"passed": true, "nvme": {"value": 0}},
|
||||
"nvme_smart_health_information_log": {
|
||||
"critical_warning": 0, "temperature": 41, "available_spare": 100, "available_spare_threshold": 10,
|
||||
"percentage_used": 3, "media_errors": 0, "num_err_log_entries": 12, "unsafe_shutdowns": 30,
|
||||
"power_cycles": 300, "power_on_hours": 1234
|
||||
},
|
||||
"temperature": {"current": 41},
|
||||
"power_cycle_count": 300,
|
||||
"power_on_time": {"hours": 1234},
|
||||
"nvme_self_test_log": {"current_self_test_operation": {"value": 0, "string": "No self-test in progress"},
|
||||
"table": [{"self_test_code": {"value": 1, "string": "Short"}, "self_test_result": {"value": 0, "string": "Completed without error"}, "power_on_hours": 1200}]}
|
||||
}`
|
||||
|
||||
const smartStandby = `{
|
||||
"smartctl": {"version": [7, 4], "exit_status": 2,
|
||||
"messages": [{"string": "Device is in STANDBY mode, exit(2)", "severity": "information"}]},
|
||||
"device": {"name": "/dev/sdb", "type": "sat", "protocol": "ATA"}
|
||||
}`
|
||||
|
||||
const smartOpenFail = `{
|
||||
"smartctl": {"version": [7, 4], "exit_status": 2,
|
||||
"messages": [{"string": "Smartctl open device: /dev/sdz failed: No such device", "severity": "error"}]}
|
||||
}`
|
||||
|
||||
const smartInProgress = `{
|
||||
"smartctl": {"version": [7, 4], "exit_status": 0},
|
||||
"device": {"protocol": "ATA"},
|
||||
"serial_number": "X",
|
||||
"smart_support": {"available": true, "enabled": true},
|
||||
"smart_status": {"passed": true},
|
||||
"ata_smart_data": {"self_test": {"status": {"value": 249, "string": "in progress, 90% remaining", "remaining_percent": 90}}},
|
||||
"ata_smart_self_test_log": {"standard": {"table": [
|
||||
{"type": {"string": "Short offline"}, "status": {"string": "Completed: read failure", "passed": false}, "lifetime_hours": 10}]}}
|
||||
}`
|
||||
|
||||
func TestParseSmartATA(t *testing.T) {
|
||||
s := parseSmart([]byte(smartATA))
|
||||
if s.Error != "" || s.Standby {
|
||||
t.Fatalf("unexpected error/standby: %+v", s)
|
||||
}
|
||||
if s.Protocol != "ATA" || s.Serial != "WD-AAA" || !s.Supported || !s.Healthy {
|
||||
t.Errorf("basic fields: %+v", s)
|
||||
}
|
||||
if !s.HasTemp || s.TempC != 34 || s.PowerOnHrs != 7541 || s.PowerCycles != 21 {
|
||||
t.Errorf("temp/hours: %+v", s)
|
||||
}
|
||||
if a, ok := s.Attr(197); !ok || a.Raw != 8 {
|
||||
t.Errorf("attr 197: %+v %v", a, ok)
|
||||
}
|
||||
if a, ok := s.Attr(188); !ok || a.Raw != 3 {
|
||||
t.Errorf("attr 188 must be unpacked to the low word: %+v", a)
|
||||
}
|
||||
if s.LastTest == nil || s.LastTest.InProgress || !s.LastTest.Passed || s.LastTest.Type != "Extended offline" || s.LastTest.Hours != 7500 {
|
||||
t.Errorf("last test: %+v", s.LastTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartNVMe(t *testing.T) {
|
||||
s := parseSmart([]byte(smartNVMe))
|
||||
if s.NVMe == nil {
|
||||
t.Fatalf("nvme log missing: %+v", s)
|
||||
}
|
||||
if s.NVMe.PercentageUsed != 3 || s.NVMe.AvailableSpare != 100 || s.NVMe.ErrLogEntries != 12 {
|
||||
t.Errorf("nvme: %+v", s.NVMe)
|
||||
}
|
||||
if s.TempC != 41 || s.PowerOnHrs != 1234 || !s.Healthy {
|
||||
t.Errorf("fields: %+v", s)
|
||||
}
|
||||
if s.LastTest == nil || s.LastTest.Type != "Short" || !s.LastTest.Passed {
|
||||
t.Errorf("last test: %+v", s.LastTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartStandby(t *testing.T) {
|
||||
s := parseSmart([]byte(smartStandby))
|
||||
if !s.Standby || s.Error != "" {
|
||||
t.Errorf("standby not detected: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartOpenFail(t *testing.T) {
|
||||
s := parseSmart([]byte(smartOpenFail))
|
||||
if s.Standby || s.Error == "" || s.Supported {
|
||||
t.Errorf("open failure not reported: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartInProgress(t *testing.T) {
|
||||
s := parseSmart([]byte(smartInProgress))
|
||||
if s.LastTest == nil || !s.LastTest.InProgress || s.LastTest.Remaining != 90 {
|
||||
t.Errorf("in-progress test: %+v", s.LastTest)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSmartGarbage(t *testing.T) {
|
||||
s := parseSmart([]byte("not json"))
|
||||
if s.Error == "" {
|
||||
t.Errorf("expected parse error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLsblk(t *testing.T) {
|
||||
in := `{"blockdevices": [
|
||||
{"name":"sda","path":"/dev/sda","type":"disk","model":"WDC WD80EFZZ-68BTXN0","serial":"WD-AAA","size":8001563222016,"rota":true,"tran":"sata"},
|
||||
{"name":"nvme0n1","path":"/dev/nvme0n1","type":"disk","model":"Samsung SSD 980 500GB","serial":"S64ANL0T","size":500107862016,"rota":false,"tran":"nvme"},
|
||||
{"name":"loop0","path":"/dev/loop0","type":"loop","model":null,"serial":null,"size":4096,"rota":false,"tran":null},
|
||||
{"name":"zd0","path":"/dev/zd0","type":"disk","model":null,"serial":null,"size":4096,"rota":false,"tran":null},
|
||||
{"name":"sr0","path":"/dev/sr0","type":"rom","model":"DVD","serial":null,"size":1024,"rota":true,"tran":"sata"}
|
||||
]}`
|
||||
disks, err := parseLsblk([]byte(in))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(disks) != 2 || disks[0].Name != "sda" || !disks[0].Rota || disks[1].Tran != "nvme" || disks[1].Rota {
|
||||
t.Errorf("disks = %+v", disks)
|
||||
}
|
||||
// older util-linux prints rota as a string
|
||||
old := `{"blockdevices": [{"name":"sda","type":"disk","size":"1000","rota":"1","tran":"sata"}]}`
|
||||
disks, err = parseLsblk([]byte(old))
|
||||
if err != nil || len(disks) != 1 || !disks[0].Rota || disks[0].Path != "/dev/sda" {
|
||||
t.Errorf("old lsblk: %+v %v", disks, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Temp struct {
|
||||
Chip string `json:"chip"`
|
||||
Label string `json:"label"`
|
||||
C float64 `json:"c"`
|
||||
}
|
||||
|
||||
// ReadTemps lists every hwmon temperature input; falls back to thermal zones.
|
||||
func ReadTemps() []Temp {
|
||||
temps := readHwmon("/sys/class/hwmon")
|
||||
if len(temps) == 0 {
|
||||
temps = readThermalZones("/sys/class/thermal")
|
||||
}
|
||||
sort.Slice(temps, func(i, j int) bool {
|
||||
if temps[i].Chip != temps[j].Chip {
|
||||
return temps[i].Chip < temps[j].Chip
|
||||
}
|
||||
return temps[i].Label < temps[j].Label
|
||||
})
|
||||
return temps
|
||||
}
|
||||
|
||||
func readHwmon(root string) []Temp {
|
||||
var out []Temp
|
||||
chips, _ := filepath.Glob(filepath.Join(root, "hwmon*"))
|
||||
for _, chip := range chips {
|
||||
name := readTrim(filepath.Join(chip, "name"))
|
||||
inputs, _ := filepath.Glob(filepath.Join(chip, "temp*_input"))
|
||||
for _, in := range inputs {
|
||||
v, err := strconv.ParseFloat(readTrim(in), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
base := strings.TrimSuffix(in, "_input")
|
||||
label := readTrim(base + "_label")
|
||||
if label == "" {
|
||||
label = filepath.Base(base)
|
||||
}
|
||||
out = append(out, Temp{Chip: name, Label: label, C: v / 1000})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func readThermalZones(root string) []Temp {
|
||||
var out []Temp
|
||||
zones, _ := filepath.Glob(filepath.Join(root, "thermal_zone*"))
|
||||
for _, z := range zones {
|
||||
v, err := strconv.ParseFloat(readTrim(filepath.Join(z, "temp")), 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Temp{Chip: "thermal", Label: readTrim(filepath.Join(z, "type")), C: v / 1000})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// CPUTemp picks the most plausible CPU package temperature from the list.
|
||||
func CPUTemp(temps []Temp) (float64, bool) {
|
||||
best, found := 0.0, false
|
||||
for _, t := range temps {
|
||||
chip := strings.ToLower(t.Chip)
|
||||
lbl := strings.ToLower(t.Label)
|
||||
isCPU := chip == "coretemp" || chip == "k10temp" || chip == "zenpower" ||
|
||||
strings.Contains(lbl, "package") || strings.Contains(lbl, "tctl") || strings.Contains(lbl, "tdie") ||
|
||||
strings.Contains(lbl, "x86_pkg_temp") || strings.Contains(lbl, "cpu")
|
||||
if isCPU && (!found || t.C > best) {
|
||||
best, found = t.C, true
|
||||
}
|
||||
}
|
||||
return best, found
|
||||
}
|
||||
|
||||
func readTrim(p string) string {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Vdev struct {
|
||||
Name string `json:"name"`
|
||||
Depth int `json:"depth"`
|
||||
State string `json:"state"`
|
||||
Read int64 `json:"read"`
|
||||
Write int64 `json:"write"`
|
||||
Cksum int64 `json:"cksum"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
type Pool struct {
|
||||
Name string `json:"name"`
|
||||
Size uint64 `json:"size"`
|
||||
Alloc uint64 `json:"alloc"`
|
||||
Free uint64 `json:"free"`
|
||||
CapPct int `json:"cap_pct"`
|
||||
Health string `json:"health"`
|
||||
FragPct int `json:"frag_pct"`
|
||||
Scan string `json:"scan"`
|
||||
ScanErrs int64 `json:"scan_errors"`
|
||||
LastScrub *time.Time `json:"last_scrub,omitempty"`
|
||||
Scrubbing bool `json:"scrubbing"`
|
||||
Errors string `json:"errors"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Action string `json:"action,omitempty"`
|
||||
Vdevs []Vdev `json:"vdevs"`
|
||||
}
|
||||
|
||||
type Dataset struct {
|
||||
Name string `json:"name"`
|
||||
Used uint64 `json:"used"`
|
||||
Avail uint64 `json:"avail"`
|
||||
Refer uint64 `json:"refer"`
|
||||
Mountpoint string `json:"mountpoint"`
|
||||
}
|
||||
|
||||
// ReadPools combines zpool list and zpool status for every pool.
|
||||
func ReadPools() ([]Pool, error) {
|
||||
out, err := run(20*time.Second, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,cap,health,frag")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pools := parseZpoolList(out)
|
||||
for i := range pools {
|
||||
st, err := run(20*time.Second, "zpool", "status", pools[i].Name)
|
||||
if err != nil && len(st) == 0 {
|
||||
continue
|
||||
}
|
||||
parseZpoolStatus(st, &pools[i])
|
||||
}
|
||||
return pools, nil
|
||||
}
|
||||
|
||||
func ReadDatasets() ([]Dataset, error) {
|
||||
out, err := run(20*time.Second, "zfs", "list", "-Hp", "-t", "filesystem", "-o", "name,used,avail,refer,mountpoint")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseZfsList(out), nil
|
||||
}
|
||||
|
||||
func parseZpoolList(b []byte) []Pool {
|
||||
var pools []Pool
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
for sc.Scan() {
|
||||
f := strings.Split(sc.Text(), "\t")
|
||||
if len(f) < 7 {
|
||||
continue
|
||||
}
|
||||
p := Pool{Name: f[0], Health: f[5]}
|
||||
p.Size, _ = strconv.ParseUint(f[1], 10, 64)
|
||||
p.Alloc, _ = strconv.ParseUint(f[2], 10, 64)
|
||||
p.Free, _ = strconv.ParseUint(f[3], 10, 64)
|
||||
p.CapPct = atoiPct(f[4])
|
||||
p.FragPct = atoiPct(f[6])
|
||||
if p.CapPct == 0 && p.Size > 0 {
|
||||
p.CapPct = int(p.Alloc * 100 / p.Size)
|
||||
}
|
||||
pools = append(pools, p)
|
||||
}
|
||||
return pools
|
||||
}
|
||||
|
||||
func atoiPct(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSuffix(strings.TrimSpace(s), "%"))
|
||||
return n
|
||||
}
|
||||
|
||||
var (
|
||||
reSection = regexp.MustCompile(`^ *(pool|state|status|action|see|scan|scrub|config|errors|remove|checkpoint|expand):(.*)$`)
|
||||
reScanDate = regexp.MustCompile(`on (\w{3} \w{3}\s+\d+ \d+:\d+:\d+ \d{4})`)
|
||||
reScanErrs = regexp.MustCompile(`with (\d+) errors`)
|
||||
)
|
||||
|
||||
// parseZpoolStatus fills scan/errors/status/vdev tree from the text output of
|
||||
// "zpool status <pool>" (zfs 2.2 has no JSON output).
|
||||
func parseZpoolStatus(b []byte, p *Pool) {
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
section := ""
|
||||
var scanLines, statusLines, actionLines []string
|
||||
inConfig, headerSeen := false, false
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
trim := strings.TrimSpace(line)
|
||||
if inConfig {
|
||||
if trim == "" {
|
||||
if headerSeen {
|
||||
inConfig = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !headerSeen {
|
||||
if strings.HasPrefix(trim, "NAME") {
|
||||
headerSeen = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
p.Vdevs = append(p.Vdevs, parseVdevLine(line))
|
||||
continue
|
||||
}
|
||||
if m := reSection.FindStringSubmatch(line); m != nil {
|
||||
key, rest, _ := strings.Cut(trim, ":")
|
||||
key = strings.TrimSpace(key)
|
||||
rest = strings.TrimSpace(rest)
|
||||
section = key
|
||||
switch key {
|
||||
case "config":
|
||||
inConfig, headerSeen = true, false
|
||||
case "scan", "scrub":
|
||||
scanLines = append(scanLines, rest)
|
||||
case "status":
|
||||
statusLines = append(statusLines, rest)
|
||||
case "action":
|
||||
actionLines = append(actionLines, rest)
|
||||
case "errors":
|
||||
p.Errors = rest
|
||||
case "state":
|
||||
if p.Health == "" {
|
||||
p.Health = rest
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
// continuation line of the current section
|
||||
switch section {
|
||||
case "scan", "scrub":
|
||||
scanLines = append(scanLines, trim)
|
||||
case "status":
|
||||
statusLines = append(statusLines, trim)
|
||||
case "action":
|
||||
actionLines = append(actionLines, trim)
|
||||
case "errors":
|
||||
if trim != "" {
|
||||
p.Errors += " " + trim
|
||||
}
|
||||
}
|
||||
}
|
||||
p.Scan = strings.TrimSpace(strings.Join(scanLines, " "))
|
||||
p.Status = strings.TrimSpace(strings.Join(statusLines, " "))
|
||||
p.Action = strings.TrimSpace(strings.Join(actionLines, " "))
|
||||
p.Scrubbing = strings.Contains(p.Scan, "scrub in progress")
|
||||
if m := reScanErrs.FindStringSubmatch(p.Scan); m != nil {
|
||||
p.ScanErrs, _ = strconv.ParseInt(m[1], 10, 64)
|
||||
}
|
||||
if strings.HasPrefix(p.Scan, "scrub repaired") || strings.HasPrefix(p.Scan, "scrub canceled") {
|
||||
if m := reScanDate.FindStringSubmatch(p.Scan); m != nil {
|
||||
if t, err := time.ParseInLocation("Mon Jan _2 15:04:05 2006", squashSpaces(m[1]), time.Local); err == nil {
|
||||
p.LastScrub = &t
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func squashSpaces(s string) string { return strings.Join(strings.Fields(s), " ") }
|
||||
|
||||
func parseVdevLine(line string) Vdev {
|
||||
// zpool indents with a tab, then two spaces per level
|
||||
body := strings.TrimLeft(line, "\t")
|
||||
indent := len(body) - len(strings.TrimLeft(body, " "))
|
||||
f := strings.Fields(body)
|
||||
v := Vdev{Depth: indent / 2}
|
||||
if len(f) == 0 {
|
||||
return v
|
||||
}
|
||||
v.Name = f[0]
|
||||
if len(f) >= 5 {
|
||||
v.State = f[1]
|
||||
v.Read, _ = strconv.ParseInt(f[2], 10, 64)
|
||||
v.Write, _ = strconv.ParseInt(f[3], 10, 64)
|
||||
v.Cksum, _ = strconv.ParseInt(f[4], 10, 64)
|
||||
if len(f) > 5 {
|
||||
v.Note = strings.Join(f[5:], " ")
|
||||
}
|
||||
} else if len(f) > 1 {
|
||||
v.State = f[1]
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func parseZfsList(b []byte) []Dataset {
|
||||
var out []Dataset
|
||||
sc := bufio.NewScanner(bytes.NewReader(b))
|
||||
for sc.Scan() {
|
||||
f := strings.Split(sc.Text(), "\t")
|
||||
if len(f) < 5 {
|
||||
continue
|
||||
}
|
||||
d := Dataset{Name: f[0], Mountpoint: f[4]}
|
||||
d.Used, _ = strconv.ParseUint(f[1], 10, 64)
|
||||
d.Avail, _ = strconv.ParseUint(f[2], 10, 64)
|
||||
d.Refer, _ = strconv.ParseUint(f[3], 10, 64)
|
||||
out = append(out, d)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package collect
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const zpoolStatusOK = ` pool: nas_pool_1_8_gb
|
||||
state: ONLINE
|
||||
scan: scrub repaired 0B in 03:12:41 with 0 errors on Sun Sep 7 03:36:42 2025
|
||||
config:
|
||||
|
||||
NAME STATE READ WRITE CKSUM
|
||||
nas_pool_1_8_gb ONLINE 0 0 0
|
||||
mirror-0 ONLINE 0 0 0
|
||||
ata-WDC_WD80EFZZ-68BTXN0_WD-AAA ONLINE 0 0 0
|
||||
ata-WDC_WD80EFZZ-68BTXN0_WD-BBB ONLINE 0 0 0
|
||||
|
||||
errors: No known data errors
|
||||
`
|
||||
|
||||
const zpoolStatusDegraded = ` pool: tank
|
||||
state: DEGRADED
|
||||
status: One or more devices could not be opened. Sufficient replicas exist for
|
||||
the pool to continue functioning in a degraded state.
|
||||
action: Attach the missing device and online it using 'zpool online'.
|
||||
see: https://openzfs.github.io/openzfs-docs/msg/ZFS-8000-2Q
|
||||
scan: scrub in progress since Sat Sep 13 01:00:00 2025
|
||||
1.20T / 7.10T scanned at 1.02G/s, 500G / 7.10T issued at 430M/s
|
||||
0B repaired, 6.88% done, 04:28:12 to go
|
||||
config:
|
||||
|
||||
NAME STATE READ WRITE CKSUM
|
||||
tank DEGRADED 0 0 0
|
||||
mirror-0 DEGRADED 0 0 0
|
||||
ata-DISK_A ONLINE 0 0 3
|
||||
1234567890123456789 UNAVAIL 0 0 0 was /dev/sdb1
|
||||
logs
|
||||
nvme0n1p2 ONLINE 0 0 0
|
||||
|
||||
errors: Permanent errors have been detected in the following files:
|
||||
|
||||
/tank/data/file.bin
|
||||
`
|
||||
|
||||
func TestParseZpoolStatusOK(t *testing.T) {
|
||||
p := Pool{Name: "nas_pool_1_8_gb"}
|
||||
parseZpoolStatus([]byte(zpoolStatusOK), &p)
|
||||
if p.Health != "ONLINE" {
|
||||
t.Errorf("health = %q", p.Health)
|
||||
}
|
||||
if p.Errors != "No known data errors" {
|
||||
t.Errorf("errors = %q", p.Errors)
|
||||
}
|
||||
if p.Scrubbing || p.ScanErrs != 0 {
|
||||
t.Errorf("scan flags wrong: %+v", p)
|
||||
}
|
||||
if p.LastScrub == nil {
|
||||
t.Fatalf("last scrub not parsed from %q", p.Scan)
|
||||
}
|
||||
want := time.Date(2025, 9, 7, 3, 36, 42, 0, time.Local)
|
||||
if !p.LastScrub.Equal(want) {
|
||||
t.Errorf("last scrub = %v, want %v", p.LastScrub, want)
|
||||
}
|
||||
if len(p.Vdevs) != 4 {
|
||||
t.Fatalf("vdevs = %d: %+v", len(p.Vdevs), p.Vdevs)
|
||||
}
|
||||
if p.Vdevs[0].Depth != 0 || p.Vdevs[1].Depth != 1 || p.Vdevs[2].Depth != 2 {
|
||||
t.Errorf("depths wrong: %+v", p.Vdevs)
|
||||
}
|
||||
if p.Vdevs[1].Name != "mirror-0" || p.Vdevs[1].State != "ONLINE" {
|
||||
t.Errorf("mirror vdev wrong: %+v", p.Vdevs[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZpoolStatusDegraded(t *testing.T) {
|
||||
p := Pool{Name: "tank"}
|
||||
parseZpoolStatus([]byte(zpoolStatusDegraded), &p)
|
||||
if p.Health != "DEGRADED" {
|
||||
t.Errorf("health = %q", p.Health)
|
||||
}
|
||||
if !p.Scrubbing {
|
||||
t.Errorf("scrub in progress not detected: %q", p.Scan)
|
||||
}
|
||||
if p.LastScrub != nil {
|
||||
t.Errorf("last scrub must be nil while scrubbing")
|
||||
}
|
||||
if p.Status == "" || p.Action == "" {
|
||||
t.Errorf("status/action not captured: %q / %q", p.Status, p.Action)
|
||||
}
|
||||
if p.Errors == "" || p.Errors == "No known data errors" {
|
||||
t.Errorf("errors = %q", p.Errors)
|
||||
}
|
||||
var unavail, cksum, logs bool
|
||||
for _, v := range p.Vdevs {
|
||||
switch {
|
||||
case v.State == "UNAVAIL" && v.Note == "was /dev/sdb1":
|
||||
unavail = true
|
||||
case v.Name == "ata-DISK_A" && v.Cksum == 3:
|
||||
cksum = true
|
||||
case v.Name == "logs" && v.State == "":
|
||||
logs = true
|
||||
}
|
||||
}
|
||||
if !unavail || !cksum || !logs {
|
||||
t.Errorf("vdev parse: unavail=%v cksum=%v logs=%v %+v", unavail, cksum, logs, p.Vdevs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZpoolList(t *testing.T) {
|
||||
out := "nas_pool_1_8_gb\t7992048291840\t3500000000000\t4492048291840\t43\tONLINE\t7\ntmp_pool\t1000000000000\t900000000000\t100000000000\t90\tONLINE\t-\n"
|
||||
pools := parseZpoolList([]byte(out))
|
||||
if len(pools) != 2 {
|
||||
t.Fatalf("pools = %d", len(pools))
|
||||
}
|
||||
if pools[0].CapPct != 43 || pools[0].FragPct != 7 || pools[0].Health != "ONLINE" {
|
||||
t.Errorf("pool0 = %+v", pools[0])
|
||||
}
|
||||
if pools[1].CapPct != 90 || pools[1].FragPct != 0 {
|
||||
t.Errorf("pool1 = %+v", pools[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseZfsList(t *testing.T) {
|
||||
out := "nas_pool_1_8_gb\t100\t200\t50\t/nas_pool_1_8_gb\nnas_pool_1_8_gb/appdata\t10\t200\t10\t/nas_pool_1_8_gb/appdata\n"
|
||||
ds := parseZfsList([]byte(out))
|
||||
if len(ds) != 2 || ds[1].Name != "nas_pool_1_8_gb/appdata" || ds[1].Avail != 200 {
|
||||
t.Errorf("datasets = %+v", ds)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user