mmcblkNboot0/boot1/rpmb are hardware areas of the chip; hide them on Disks and Dashboard. Badge eMMC/SD instead of SSD. smartctl cannot talk to MMC, so read life_time and pre_eol_info from /sys instead of showing its error. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
890 lines
27 KiB
Go
890 lines
27 KiB
Go
// Package storage is the Disks tab: block devices from lsblk, SMART health via
|
||
// smartctl (optional; the tab explains how to get it), and ZFS pools/datasets/snapshots
|
||
// when the zfs tools and module are present. Spinning disks are never woken just to
|
||
// read SMART (-n standby).
|
||
package storage
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"os/exec"
|
||
"path/filepath"
|
||
"regexp"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"syscall"
|
||
"time"
|
||
|
||
"hamlab/internal/priv"
|
||
)
|
||
|
||
// ---- block devices ----
|
||
|
||
type Part struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"` // part | crypt | lvm | md | …
|
||
FSType string `json:"fstype,omitempty"`
|
||
Mount string `json:"mount,omitempty"`
|
||
Label string `json:"label,omitempty"`
|
||
Size uint64 `json:"size"`
|
||
Used uint64 `json:"used,omitempty"`
|
||
FSSize uint64 `json:"fs_size,omitempty"`
|
||
}
|
||
|
||
type Device struct {
|
||
Name string `json:"name"`
|
||
Kind string `json:"kind"` // nvme | ssd | hdd | usb | emmc | sd | other
|
||
Model string `json:"model,omitempty"`
|
||
Vendor string `json:"vendor,omitempty"`
|
||
Serial string `json:"serial,omitempty"`
|
||
Tran string `json:"tran,omitempty"`
|
||
Size uint64 `json:"size"`
|
||
Rotational bool `json:"rotational"`
|
||
Removable bool `json:"removable"`
|
||
Temp float64 `json:"temp,omitempty"`
|
||
Parts []Part `json:"parts"`
|
||
Namespaces []string `json:"namespaces,omitempty"` // NVMe: the block devices behind this controller
|
||
}
|
||
|
||
type lsblkNode struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Size uint64 `json:"size"`
|
||
Model *string `json:"model"`
|
||
Serial *string `json:"serial"`
|
||
Vendor *string `json:"vendor"`
|
||
Rota bool `json:"rota"`
|
||
Tran *string `json:"tran"`
|
||
FSType *string `json:"fstype"`
|
||
Mountpoint *string `json:"mountpoint"`
|
||
Label *string `json:"label"`
|
||
RM bool `json:"rm"`
|
||
Children []lsblkNode `json:"children"`
|
||
}
|
||
|
||
func str(p *string) string {
|
||
if p == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(*p)
|
||
}
|
||
|
||
func Devices(ctx context.Context) ([]Device, error) {
|
||
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||
defer cancel()
|
||
var root struct {
|
||
Blockdevices []lsblkNode `json:"blockdevices"`
|
||
}
|
||
// lsblk knows labels and stacked devices (LVM, LUKS, md); without it (Alpine and other
|
||
// busybox systems don't ship it) or with one too old for JSON, /sys has the essentials.
|
||
out, err := exec.CommandContext(ctx, "lsblk", "-J", "-b", "-o", "NAME,TYPE,SIZE,MODEL,SERIAL,VENDOR,ROTA,TRAN,FSTYPE,MOUNTPOINT,LABEL,RM").Output()
|
||
if err != nil || json.Unmarshal(out, &root) != nil {
|
||
root.Blockdevices = sysBlockDevices()
|
||
}
|
||
devs := []Device{}
|
||
for _, n := range root.Blockdevices {
|
||
if n.Type != "disk" || n.Size == 0 || strings.HasPrefix(n.Name, "zd") || mmcAreaRe.MatchString(n.Name) {
|
||
continue
|
||
}
|
||
d := Device{Name: n.Name, Model: str(n.Model), Vendor: str(n.Vendor), Serial: str(n.Serial), Tran: str(n.Tran),
|
||
Size: n.Size, Rotational: n.Rota, Removable: n.RM, Temp: blockTemp(n.Name), Parts: []Part{}}
|
||
switch {
|
||
case strings.HasPrefix(n.Name, "mmcblk"):
|
||
d.Kind = mmcKind(n.Name)
|
||
if d.Model == "" {
|
||
d.Model = strings.TrimSpace(readStrFile("/sys/block/" + n.Name + "/device/name"))
|
||
}
|
||
case d.Tran == "usb":
|
||
d.Kind = "usb"
|
||
case d.Tran == "nvme" || strings.HasPrefix(n.Name, "nvme"):
|
||
d.Kind = "nvme"
|
||
case n.Rota:
|
||
d.Kind = "hdd"
|
||
case d.Tran == "sata" || d.Tran == "sas" || strings.HasPrefix(n.Name, "sd"):
|
||
d.Kind = "ssd"
|
||
default:
|
||
d.Kind = "other"
|
||
}
|
||
var walk func(nodes []lsblkNode)
|
||
walk = func(nodes []lsblkNode) {
|
||
for _, c := range nodes {
|
||
p := Part{Name: c.Name, Type: c.Type, FSType: str(c.FSType), Mount: str(c.Mountpoint), Label: str(c.Label), Size: c.Size}
|
||
if p.Mount != "" && p.Mount != "[SWAP]" {
|
||
var st syscall.Statfs_t
|
||
if syscall.Statfs(p.Mount, &st) == nil {
|
||
bs := uint64(st.Bsize)
|
||
p.FSSize, p.Used = st.Blocks*bs, (st.Blocks-st.Bfree)*bs
|
||
}
|
||
}
|
||
d.Parts = append(d.Parts, p)
|
||
walk(c.Children)
|
||
}
|
||
}
|
||
walk(n.Children)
|
||
devs = append(devs, d)
|
||
}
|
||
devs = foldNVMe(devs)
|
||
sort.Slice(devs, func(i, j int) bool { return devs[i].Name < devs[j].Name })
|
||
return devs, nil
|
||
}
|
||
|
||
// sysBlockDevices builds the same tree lsblk would from /sys/block and /proc/mounts.
|
||
func sysBlockDevices() []lsblkNode {
|
||
type mnt struct{ point, fstype string }
|
||
mounts := map[string]mnt{}
|
||
if b, err := os.ReadFile("/proc/mounts"); err == nil {
|
||
for _, line := range strings.Split(string(b), "\n") {
|
||
if f := strings.Fields(line); len(f) >= 3 && strings.HasPrefix(f[0], "/dev/") {
|
||
dev := f[0]
|
||
if real, err := filepath.EvalSymlinks(dev); err == nil { // /dev/disk/by-uuid/…, /dev/mapper/…
|
||
dev = real
|
||
}
|
||
if _, seen := mounts[filepath.Base(dev)]; !seen {
|
||
mounts[filepath.Base(dev)] = mnt{strings.ReplaceAll(f[1], `\040`, " "), f[2]}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
if b, err := os.ReadFile("/proc/swaps"); err == nil {
|
||
for _, line := range strings.Split(string(b), "\n") {
|
||
if f := strings.Fields(line); len(f) > 0 && strings.HasPrefix(f[0], "/dev/") {
|
||
mounts[filepath.Base(f[0])] = mnt{"[SWAP]", "swap"}
|
||
}
|
||
}
|
||
}
|
||
sp := func(v string) *string {
|
||
if v = strings.TrimSpace(v); v == "" {
|
||
return nil
|
||
}
|
||
return &v
|
||
}
|
||
node := func(dir, name, typ string) lsblkNode {
|
||
n := lsblkNode{Name: name, Type: typ, Size: u64(strings.TrimSpace(readStrFile(dir+"/size"))) * 512}
|
||
if m, ok := mounts[name]; ok {
|
||
n.Mountpoint, n.FSType = sp(m.point), sp(m.fstype)
|
||
}
|
||
return n
|
||
}
|
||
var out []lsblkNode
|
||
ents, _ := os.ReadDir("/sys/block")
|
||
for _, e := range ents {
|
||
name := e.Name()
|
||
dir := "/sys/block/" + name
|
||
if strings.HasPrefix(name, "loop") || strings.HasPrefix(name, "ram") || strings.HasPrefix(name, "zram") ||
|
||
strings.HasPrefix(name, "dm-") || strings.HasPrefix(name, "md") || strings.HasPrefix(name, "sr") || strings.HasPrefix(name, "nbd") {
|
||
continue
|
||
}
|
||
n := node(dir, name, "disk")
|
||
n.Model, n.Vendor = sp(readStrFile(dir+"/device/model")), sp(readStrFile(dir+"/device/vendor"))
|
||
n.Serial = sp(readStrFile(dir + "/device/serial"))
|
||
n.Rota = strings.TrimSpace(readStrFile(dir+"/queue/rotational")) == "1"
|
||
n.RM = strings.TrimSpace(readStrFile(dir+"/removable")) == "1"
|
||
real, _ := filepath.EvalSymlinks(dir)
|
||
switch {
|
||
case strings.HasPrefix(name, "nvme"):
|
||
n.Tran = sp("nvme")
|
||
case strings.Contains(real, "/usb"):
|
||
n.Tran = sp("usb")
|
||
case strings.Contains(real, "/ata"):
|
||
n.Tran = sp("sata")
|
||
}
|
||
subs, _ := os.ReadDir(dir)
|
||
for _, sub := range subs {
|
||
if _, err := os.Stat(dir + "/" + sub.Name() + "/partition"); err == nil {
|
||
n.Children = append(n.Children, node(dir+"/"+sub.Name(), sub.Name(), "part"))
|
||
}
|
||
}
|
||
out = append(out, n)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// An eMMC chip's boot and RPMB areas are hardware regions of the same chip, not drives;
|
||
// the kernel lists them as disks of their own (mmcblk0boot0, mmcblk0boot1, mmcblk0rpmb).
|
||
var mmcAreaRe = regexp.MustCompile(`^mmcblk\d+(boot\d+|rpmb|gp\d+)$`)
|
||
|
||
func mmcKind(name string) string {
|
||
if strings.TrimSpace(readStrFile("/sys/block/"+name+"/device/type")) == "MMC" {
|
||
return "emmc"
|
||
}
|
||
return "sd"
|
||
}
|
||
|
||
// mmcHealth is the eMMC answer to SMART: the chip's own wear and end-of-life registers,
|
||
// which the kernel publishes in /sys. SD cards have neither; smartctl speaks to neither.
|
||
func mmcHealth(s Smart) Smart {
|
||
dir := "/sys/block/" + s.Device + "/device/"
|
||
s.Available, s.Supported = true, true
|
||
s.SelfTestNote = "not a feature of eMMC or SD"
|
||
s.Serial = strings.TrimSpace(readStrFile(dir + "serial"))
|
||
s.Firmware = strings.TrimSpace(readStrFile(dir + "fwrev"))
|
||
s.Model = strings.TrimSpace(readStrFile(dir + "name"))
|
||
hex := func(v string) int { n, _ := strconv.ParseInt(strings.TrimPrefix(v, "0x"), 16, 32); return int(n) }
|
||
life := strings.Fields(readStrFile(dir + "life_time"))
|
||
eol := strings.TrimSpace(readStrFile(dir + "pre_eol_info"))
|
||
if len(life) != 2 || eol == "" {
|
||
s.Error = "This card reports no health data (SD cards don't; eMMC 5.0 and later does)."
|
||
return s
|
||
}
|
||
m := &MMCHealth{LifeA: hex(life[0]), LifeB: hex(life[1]), PreEOL: hex(eol)}
|
||
s.MMC = m
|
||
ok := m.PreEOL <= 1 && m.LifeA < 10 && m.LifeB < 10
|
||
s.Passed = &ok
|
||
if m.PreEOL == 2 {
|
||
s.Warnings = append(s.Warnings, "80% of reserved blocks consumed")
|
||
} else if m.PreEOL >= 3 {
|
||
s.Warnings = append(s.Warnings, "90% of reserved blocks consumed — replace soon")
|
||
}
|
||
if m.LifeA >= 10 || m.LifeB >= 10 {
|
||
s.Warnings = append(s.Warnings, "estimated lifetime reached")
|
||
}
|
||
return s
|
||
}
|
||
|
||
var nsRe = regexp.MustCompile(`^(nvme\d+)n\d+$`)
|
||
|
||
// foldNVMe merges nvme0n1, nvme0n2, … into one physical device "nvme0": one SSD, one
|
||
// SMART record, however many namespaces the controller presents (Apple exposes three).
|
||
func foldNVMe(devs []Device) []Device {
|
||
byCtrl := map[string]*Device{}
|
||
var out []Device
|
||
for _, d := range devs {
|
||
m := nsRe.FindStringSubmatch(d.Name)
|
||
if m == nil {
|
||
out = append(out, d)
|
||
continue
|
||
}
|
||
ctrl := m[1]
|
||
c, ok := byCtrl[ctrl]
|
||
if !ok {
|
||
nd := d
|
||
nd.Name = ctrl
|
||
nd.Parts = []Part{}
|
||
nd.Namespaces = nil
|
||
if mdl := strings.TrimSpace(readStrFile("/sys/class/nvme/" + ctrl + "/model")); mdl != "" {
|
||
nd.Model = mdl
|
||
}
|
||
if sn := strings.TrimSpace(readStrFile("/sys/class/nvme/" + ctrl + "/serial")); sn != "" {
|
||
nd.Serial = sn
|
||
}
|
||
if t := ctrlTemp(ctrl); t > 0 {
|
||
nd.Temp = t
|
||
}
|
||
nd.Size = 0
|
||
c = &nd
|
||
byCtrl[ctrl] = c
|
||
out = append(out, Device{}) // placeholder keeps order; filled below
|
||
c.Namespaces = []string{}
|
||
}
|
||
c.Size += d.Size
|
||
c.Namespaces = append(c.Namespaces, d.Name)
|
||
c.Parts = append(c.Parts, d.Parts...)
|
||
}
|
||
// replace placeholders with the folded controllers, in first-seen order
|
||
res := out[:0]
|
||
seen := map[string]bool{}
|
||
for _, d := range out {
|
||
if d.Name != "" {
|
||
res = append(res, d)
|
||
continue
|
||
}
|
||
for _, c := range byCtrl {
|
||
if !seen[c.Name] {
|
||
seen[c.Name] = true
|
||
res = append(res, *c)
|
||
break
|
||
}
|
||
}
|
||
}
|
||
return res
|
||
}
|
||
|
||
func readStrFile(p string) string {
|
||
b, err := os.ReadFile(p)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return string(b)
|
||
}
|
||
|
||
func ctrlTemp(ctrl string) float64 {
|
||
dirs, _ := filepath.Glob("/sys/class/nvme/" + ctrl + "/hwmon*")
|
||
for _, d := range dirs {
|
||
if b, err := os.ReadFile(filepath.Join(d, "temp1_input")); err == nil {
|
||
if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil {
|
||
return float64(v) / 1000
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func blockTemp(name string) float64 {
|
||
for _, pat := range []string{"/sys/block/" + name + "/device/hwmon*", "/sys/block/" + name + "/device/hwmon/hwmon*"} {
|
||
dirs, _ := filepath.Glob(pat)
|
||
for _, d := range dirs {
|
||
if b, err := os.ReadFile(filepath.Join(d, "temp1_input")); err == nil {
|
||
if v, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil {
|
||
return float64(v) / 1000
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return 0
|
||
}
|
||
|
||
var devRe = regexp.MustCompile(`^[a-z][a-z0-9]*$`)
|
||
|
||
func validDevice(name string) bool {
|
||
if !devRe.MatchString(name) {
|
||
return false
|
||
}
|
||
if _, err := os.Stat("/sys/block/" + name); err == nil {
|
||
return true
|
||
}
|
||
_, err := os.Stat("/sys/class/nvme/" + name) // NVMe controller (nvme0), which is what smartctl wants anyway
|
||
return err == nil
|
||
}
|
||
|
||
// ---- SMART ----
|
||
|
||
type Attr struct {
|
||
ID int `json:"id"`
|
||
Name string `json:"name"`
|
||
Value int `json:"value"`
|
||
Worst int `json:"worst"`
|
||
Thresh int `json:"thresh"`
|
||
Raw string `json:"raw"`
|
||
Failed string `json:"failed,omitempty"`
|
||
Warn bool `json:"warn"`
|
||
}
|
||
|
||
type Smart struct {
|
||
Device string `json:"device"`
|
||
Available bool `json:"available"` // smartctl installed
|
||
Supported bool `json:"supported"` // device answered
|
||
Standby bool `json:"standby"` // spun down; not read
|
||
Passed *bool `json:"passed"`
|
||
Temp int `json:"temp,omitempty"`
|
||
PowerOnHours int `json:"power_on_hours,omitempty"`
|
||
PowerCycles int `json:"power_cycles,omitempty"`
|
||
Model string `json:"model,omitempty"`
|
||
Serial string `json:"serial,omitempty"`
|
||
Firmware string `json:"firmware,omitempty"`
|
||
Attrs []Attr `json:"attrs"`
|
||
NVMe *NVMeLog `json:"nvme,omitempty"`
|
||
MMC *MMCHealth `json:"mmc,omitempty"`
|
||
SelfTest string `json:"self_test,omitempty"`
|
||
SelfTestPct int `json:"self_test_remaining,omitempty"`
|
||
SelfTestOK bool `json:"self_test_supported"`
|
||
SelfTestNote string `json:"self_test_note,omitempty"`
|
||
Warnings []string `json:"warnings"`
|
||
Error string `json:"error,omitempty"`
|
||
FetchedAt int64 `json:"fetched_at"`
|
||
}
|
||
|
||
// MMCHealth is the eMMC EXT_CSD wear report. Life values are tenths: 1 = 0–10% of rated
|
||
// life used, 10 = 90–100%, 11 = exceeded, 0 = not reported. A is the SLC area, B the MLC.
|
||
type MMCHealth struct {
|
||
LifeA int `json:"life_a"`
|
||
LifeB int `json:"life_b"`
|
||
PreEOL int `json:"pre_eol"` // 1 normal · 2 warning · 3 urgent
|
||
}
|
||
|
||
type NVMeLog struct {
|
||
CriticalWarning int `json:"critical_warning"`
|
||
PercentageUsed int `json:"percentage_used"`
|
||
AvailableSpare int `json:"available_spare"`
|
||
SpareThreshold int `json:"spare_threshold"`
|
||
MediaErrors int64 `json:"media_errors"`
|
||
ErrorLogEntries int64 `json:"error_log_entries"`
|
||
DataUnitsRead uint64 `json:"data_read_bytes"`
|
||
DataUnitsWritten uint64 `json:"data_written_bytes"`
|
||
UnsafeShutdowns int64 `json:"unsafe_shutdowns"`
|
||
}
|
||
|
||
type Manager struct {
|
||
sudo bool
|
||
smartctl bool
|
||
mu sync.Mutex
|
||
cache map[string]Smart
|
||
}
|
||
|
||
// lookPath also checks the sbin directories, which a user's PATH often lacks.
|
||
func lookPath(bin string) (string, bool) {
|
||
if p, err := exec.LookPath(bin); err == nil {
|
||
return p, true
|
||
}
|
||
for _, d := range []string{"/usr/sbin", "/sbin", "/usr/local/sbin"} {
|
||
if _, err := os.Stat(d + "/" + bin); err == nil {
|
||
return d + "/" + bin, true
|
||
}
|
||
}
|
||
return "", false
|
||
}
|
||
|
||
func New(sudo bool) *Manager {
|
||
_, ok := lookPath("smartctl")
|
||
return &Manager{sudo: sudo, smartctl: ok, cache: map[string]Smart{}}
|
||
}
|
||
|
||
// HasSmartctl re-probes when it was missing at startup, so an install from the Disks
|
||
// tab takes effect without a daemon restart.
|
||
func (m *Manager) HasSmartctl() bool {
|
||
if !m.smartctl {
|
||
_, m.smartctl = lookPath("smartctl")
|
||
}
|
||
return m.smartctl
|
||
}
|
||
|
||
func (m *Manager) run(ctx context.Context, args ...string) ([]byte, int, error) {
|
||
if p, ok := lookPath(args[0]); ok {
|
||
args[0] = p
|
||
}
|
||
if m.sudo {
|
||
args = priv.Wrap(args...)
|
||
}
|
||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
cmd := exec.CommandContext(ctx, args[0], args[1:]...)
|
||
var out, errb bytes.Buffer
|
||
cmd.Stdout, cmd.Stderr = &out, &errb
|
||
err := cmd.Run()
|
||
code := 0
|
||
var ee *exec.ExitError
|
||
if errors.As(err, &ee) {
|
||
code = ee.ExitCode()
|
||
err = nil // smartctl and zpool use exit codes as status; the caller decides
|
||
if len(out.Bytes()) == 0 && errb.Len() > 0 {
|
||
err = errors.New(strings.TrimSpace(strings.Split(errb.String(), "\n")[0])) // nothing usable came back; say why
|
||
}
|
||
}
|
||
if err != nil {
|
||
msg := strings.TrimSpace(errb.String())
|
||
if msg == "" {
|
||
msg = err.Error()
|
||
}
|
||
return out.Bytes(), code, errors.New(msg)
|
||
}
|
||
return out.Bytes(), code, nil
|
||
}
|
||
|
||
func num(v any) int64 {
|
||
switch x := v.(type) {
|
||
case float64:
|
||
return int64(x)
|
||
case json.Number:
|
||
n, _ := x.Int64()
|
||
return n
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// Smart reads SMART for one device. Cached for 10 minutes; spinning disks in standby are
|
||
// left asleep and reported as such. force re-reads (and will wake the disk).
|
||
func (m *Manager) Smart(ctx context.Context, dev string, force bool) Smart {
|
||
s := Smart{Device: dev, Available: m.smartctl, Attrs: []Attr{}, Warnings: []string{}, FetchedAt: time.Now().UnixMilli()}
|
||
if !validDevice(dev) {
|
||
s.Error = "unknown device"
|
||
return s
|
||
}
|
||
if strings.HasPrefix(dev, "mmcblk") {
|
||
return mmcHealth(s)
|
||
}
|
||
if !m.HasSmartctl() {
|
||
return s
|
||
}
|
||
m.mu.Lock()
|
||
if c, ok := m.cache[dev]; ok && !force && time.Since(time.UnixMilli(c.FetchedAt)) < 10*time.Minute {
|
||
m.mu.Unlock()
|
||
return c
|
||
}
|
||
m.mu.Unlock()
|
||
|
||
args := []string{"smartctl", "-j", "-a"}
|
||
if !force {
|
||
args = append(args, "-n", "standby")
|
||
}
|
||
args = append(args, "/dev/"+dev)
|
||
out, code, err := m.run(ctx, args...)
|
||
if err != nil && len(out) == 0 {
|
||
s.Error = err.Error()
|
||
return s
|
||
}
|
||
var raw map[string]any
|
||
if json.Unmarshal(out, &raw) != nil {
|
||
s.Error = "smartctl: unreadable output"
|
||
return s
|
||
}
|
||
if code&2 != 0 { // -n standby: device is asleep (also "open failed", but JSON will say)
|
||
if msgs, ok := raw["smartctl"].(map[string]any); ok {
|
||
for _, mm := range asList(msgs["messages"]) {
|
||
if strings.Contains(strings.ToLower(asString(mm["string"])), "standby") {
|
||
s.Standby = true
|
||
}
|
||
}
|
||
}
|
||
if s.Standby {
|
||
m.mu.Lock()
|
||
m.cache[dev] = s
|
||
m.mu.Unlock()
|
||
return s
|
||
}
|
||
}
|
||
if st, ok := raw["smart_status"].(map[string]any); ok {
|
||
p := st["passed"] == true
|
||
s.Passed = &p
|
||
s.Supported = true
|
||
}
|
||
s.Model = asString(raw["model_name"])
|
||
s.Serial = asString(raw["serial_number"])
|
||
s.Firmware = asString(raw["firmware_version"])
|
||
if t, ok := raw["temperature"].(map[string]any); ok {
|
||
s.Temp = int(num(t["current"]))
|
||
}
|
||
if t, ok := raw["power_on_time"].(map[string]any); ok {
|
||
s.PowerOnHours = int(num(t["hours"]))
|
||
}
|
||
s.PowerCycles = int(num(raw["power_cycle_count"]))
|
||
|
||
if a, ok := raw["ata_smart_attributes"].(map[string]any); ok {
|
||
for _, row := range asList(a["table"]) {
|
||
at := Attr{ID: int(num(row["id"])), Name: asString(row["name"]), Value: int(num(row["value"])), Worst: int(num(row["worst"])), Thresh: int(num(row["thresh"])), Failed: asString(row["when_failed"])}
|
||
if rw, ok := row["raw"].(map[string]any); ok {
|
||
at.Raw = asString(rw["string"])
|
||
rv := num(rw["value"])
|
||
switch at.ID {
|
||
case 5, 187, 188, 196, 197, 198:
|
||
at.Warn = rv > 0
|
||
case 199:
|
||
at.Warn = rv > 0
|
||
case 10, 11:
|
||
at.Warn = rv > 100
|
||
}
|
||
if at.Warn {
|
||
s.Warnings = append(s.Warnings, at.Name+" = "+at.Raw)
|
||
}
|
||
}
|
||
if at.Failed != "" {
|
||
at.Warn = true
|
||
s.Warnings = append(s.Warnings, at.Name+" failed ("+at.Failed+")")
|
||
}
|
||
s.Attrs = append(s.Attrs, at)
|
||
}
|
||
}
|
||
if n, ok := raw["nvme_smart_health_information_log"].(map[string]any); ok {
|
||
l := &NVMeLog{CriticalWarning: int(num(n["critical_warning"])), PercentageUsed: int(num(n["percentage_used"])),
|
||
AvailableSpare: int(num(n["available_spare"])), SpareThreshold: int(num(n["available_spare_threshold"])),
|
||
MediaErrors: num(n["media_errors"]), ErrorLogEntries: num(n["num_err_log_entries"]), UnsafeShutdowns: num(n["unsafe_shutdowns"]),
|
||
DataUnitsRead: uint64(num(n["data_units_read"])) * 512000, DataUnitsWritten: uint64(num(n["data_units_written"])) * 512000}
|
||
s.NVMe = l
|
||
s.Supported = true
|
||
if l.CriticalWarning != 0 {
|
||
s.Warnings = append(s.Warnings, "critical warning flags set")
|
||
}
|
||
if l.MediaErrors > 0 {
|
||
s.Warnings = append(s.Warnings, "media errors: "+strconv.FormatInt(l.MediaErrors, 10))
|
||
}
|
||
if l.SpareThreshold > 0 && l.AvailableSpare < l.SpareThreshold {
|
||
s.Warnings = append(s.Warnings, "available spare below threshold")
|
||
}
|
||
if l.PercentageUsed >= 90 {
|
||
s.Warnings = append(s.Warnings, "endurance "+strconv.Itoa(l.PercentageUsed)+"% used")
|
||
}
|
||
}
|
||
// Can this drive run self-tests from here? ATA says so outright; NVMe drives prove it
|
||
// by exposing the self-test log, and some (WD SN580 among them) advertise the command
|
||
// but reject the log page, which makes the whole feature unusable.
|
||
if d, ok := raw["ata_smart_data"].(map[string]any); ok {
|
||
if caps, ok := d["capabilities"].(map[string]any); ok {
|
||
s.SelfTestOK = caps["self_tests_supported"] == true
|
||
if !s.SelfTestOK {
|
||
s.SelfTestNote = "drive reports no self-test support"
|
||
}
|
||
}
|
||
}
|
||
if _, ok := raw["nvme_self_test_log"]; ok {
|
||
s.SelfTestOK = true
|
||
} else if s.NVMe != nil {
|
||
s.SelfTestNote = "this drive's firmware doesn't expose the NVMe self-test log, so tests can't be run or read from here"
|
||
}
|
||
if msgs, ok := raw["smartctl"].(map[string]any); ok {
|
||
for _, mm := range asList(msgs["messages"]) {
|
||
if strings.Contains(asString(mm["string"]), "Self-test Log failed") {
|
||
s.SelfTestOK = false
|
||
s.SelfTestNote = "firmware rejects the self-test log page (" + asString(mm["string"]) + ")"
|
||
}
|
||
}
|
||
}
|
||
if d, ok := raw["ata_smart_data"].(map[string]any); ok {
|
||
if st, ok := d["self_test"].(map[string]any); ok {
|
||
if ss, ok := st["status"].(map[string]any); ok {
|
||
s.SelfTest = asString(ss["string"])
|
||
s.SelfTestPct = int(num(ss["remaining_percent"]))
|
||
}
|
||
}
|
||
}
|
||
if s.Passed != nil && !*s.Passed {
|
||
s.Warnings = append([]string{"SMART overall health: FAILED"}, s.Warnings...)
|
||
}
|
||
if !s.Supported && s.Error == "" {
|
||
s.Error = "device does not report SMART"
|
||
if msgs, ok := raw["smartctl"].(map[string]any); ok {
|
||
for _, mm := range asList(msgs["messages"]) {
|
||
if asString(mm["severity"]) == "error" {
|
||
s.Error = asString(mm["string"])
|
||
}
|
||
}
|
||
}
|
||
}
|
||
m.mu.Lock()
|
||
m.cache[dev] = s
|
||
m.mu.Unlock()
|
||
return s
|
||
}
|
||
|
||
func asList(v any) []map[string]any {
|
||
var out []map[string]any
|
||
if l, ok := v.([]any); ok {
|
||
for _, x := range l {
|
||
if mm, ok := x.(map[string]any); ok {
|
||
out = append(out, mm)
|
||
}
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func asString(v any) string {
|
||
if s, ok := v.(string); ok {
|
||
return s
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// SelfTest starts a short or long self-test.
|
||
func (m *Manager) SelfTest(ctx context.Context, dev, kind string) error {
|
||
if !validDevice(dev) {
|
||
return errors.New("unknown device")
|
||
}
|
||
if kind != "short" && kind != "long" {
|
||
return errors.New("test must be short or long")
|
||
}
|
||
if !m.smartctl {
|
||
return errors.New("smartctl is not installed")
|
||
}
|
||
if sm := m.Smart(ctx, dev, false); sm.Supported && !sm.SelfTestOK {
|
||
return errors.New("self-tests aren't available on this drive: " + sm.SelfTestNote)
|
||
}
|
||
out, code, err := m.run(ctx, "smartctl", "-t", kind, "/dev/"+dev)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if code != 0 {
|
||
return errors.New(strings.TrimSpace(lastLine(string(out))))
|
||
}
|
||
m.mu.Lock()
|
||
delete(m.cache, dev)
|
||
m.mu.Unlock()
|
||
return nil
|
||
}
|
||
|
||
func lastLine(s string) string {
|
||
lines := strings.Split(strings.TrimSpace(s), "\n")
|
||
return lines[len(lines)-1]
|
||
}
|
||
|
||
// ---- ZFS ----
|
||
|
||
type Pool struct {
|
||
Name string `json:"name"`
|
||
Size uint64 `json:"size"`
|
||
Alloc uint64 `json:"alloc"`
|
||
Free uint64 `json:"free"`
|
||
Frag int `json:"frag"`
|
||
Cap int `json:"cap"`
|
||
Health string `json:"health"`
|
||
Dedup string `json:"dedup"`
|
||
Scan string `json:"scan,omitempty"`
|
||
Errors string `json:"errors,omitempty"`
|
||
Config string `json:"config,omitempty"` // vdev tree, verbatim
|
||
}
|
||
|
||
type Dataset struct {
|
||
Name string `json:"name"`
|
||
Type string `json:"type"`
|
||
Used uint64 `json:"used"`
|
||
Avail uint64 `json:"avail"`
|
||
Refer uint64 `json:"refer"`
|
||
UsedBySnap uint64 `json:"used_by_snapshots"`
|
||
Mount string `json:"mount,omitempty"`
|
||
Compress string `json:"compressratio"`
|
||
Snapshots int `json:"snapshots"`
|
||
}
|
||
|
||
type Snapshot struct {
|
||
Name string `json:"name"`
|
||
Used uint64 `json:"used"`
|
||
Created int64 `json:"created"` // unix ms
|
||
}
|
||
|
||
type ZFS struct {
|
||
Available bool `json:"available"`
|
||
Message string `json:"message,omitempty"`
|
||
Pools []Pool `json:"pools"`
|
||
Datasets []Dataset `json:"datasets"`
|
||
}
|
||
|
||
var poolRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.-]*$`)
|
||
var datasetRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_.:/-]*$`)
|
||
var snapRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_.:-]*$`)
|
||
|
||
func u64(s string) uint64 { v, _ := strconv.ParseUint(s, 10, 64); return v }
|
||
func atoi(s string) int { v, _ := strconv.Atoi(strings.TrimSuffix(s, "%")); return v }
|
||
|
||
func (m *Manager) ZFS(ctx context.Context) ZFS {
|
||
z := ZFS{Pools: []Pool{}, Datasets: []Dataset{}}
|
||
if _, ok := lookPath("zpool"); !ok {
|
||
z.Message = "ZFS tools not installed"
|
||
return z
|
||
}
|
||
out, code, err := m.run(ctx, "zpool", "list", "-Hp", "-o", "name,size,alloc,free,frag,cap,health,dedup")
|
||
if err != nil || code != 0 {
|
||
msg := "zpool list failed"
|
||
if err != nil {
|
||
msg = err.Error()
|
||
}
|
||
z.Message = msg
|
||
return z
|
||
}
|
||
z.Available = true
|
||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||
f := strings.Split(line, "\t")
|
||
if len(f) < 8 {
|
||
continue
|
||
}
|
||
p := Pool{Name: f[0], Size: u64(f[1]), Alloc: u64(f[2]), Free: u64(f[3]), Frag: atoi(f[4]), Cap: atoi(f[5]), Health: f[6], Dedup: f[7]}
|
||
if st, _, err := m.run(ctx, "zpool", "status", p.Name); err == nil {
|
||
p.Scan, p.Errors, p.Config = parseStatus(string(st))
|
||
}
|
||
z.Pools = append(z.Pools, p)
|
||
}
|
||
snapCount := map[string]int{}
|
||
if so, _, err := m.run(ctx, "zfs", "list", "-t", "snapshot", "-H", "-o", "name"); err == nil {
|
||
for _, line := range strings.Split(strings.TrimSpace(string(so)), "\n") {
|
||
if ds, _, ok := strings.Cut(line, "@"); ok {
|
||
snapCount[ds]++
|
||
}
|
||
}
|
||
}
|
||
if do, _, err := m.run(ctx, "zfs", "list", "-Hp", "-t", "filesystem,volume", "-o", "name,type,used,avail,refer,usedbysnapshots,mountpoint,compressratio"); err == nil {
|
||
for _, line := range strings.Split(strings.TrimSpace(string(do)), "\n") {
|
||
f := strings.Split(line, "\t")
|
||
if len(f) < 8 {
|
||
continue
|
||
}
|
||
d := Dataset{Name: f[0], Type: f[1], Used: u64(f[2]), Avail: u64(f[3]), Refer: u64(f[4]), UsedBySnap: u64(f[5]), Compress: f[7], Snapshots: snapCount[f[0]]}
|
||
if f[6] != "-" && f[6] != "none" && f[6] != "legacy" {
|
||
d.Mount = f[6]
|
||
}
|
||
z.Datasets = append(z.Datasets, d)
|
||
}
|
||
}
|
||
return z
|
||
}
|
||
|
||
// parseStatus pulls the scan line, the errors line and the vdev table out of zpool status.
|
||
func parseStatus(text string) (scan, errs, config string) {
|
||
lines := strings.Split(text, "\n")
|
||
inConfig := false
|
||
var cfg []string
|
||
for i := 0; i < len(lines); i++ {
|
||
l := lines[i]
|
||
t := strings.TrimSpace(l)
|
||
switch {
|
||
case strings.HasPrefix(t, "scan:"):
|
||
scan = strings.TrimSpace(strings.TrimPrefix(t, "scan:"))
|
||
for i+1 < len(lines) && strings.HasPrefix(lines[i+1], "\t") && !strings.Contains(lines[i+1], ":") {
|
||
i++
|
||
scan += " " + strings.TrimSpace(lines[i])
|
||
}
|
||
case strings.HasPrefix(t, "errors:"):
|
||
errs = strings.TrimSpace(strings.TrimPrefix(t, "errors:"))
|
||
inConfig = false
|
||
case strings.HasPrefix(t, "config:"):
|
||
inConfig = true
|
||
case inConfig && t != "":
|
||
cfg = append(cfg, strings.TrimPrefix(l, "\t"))
|
||
}
|
||
}
|
||
return scan, errs, strings.Join(cfg, "\n")
|
||
}
|
||
|
||
func (m *Manager) Snapshots(ctx context.Context, dataset string) ([]Snapshot, error) {
|
||
if !datasetRe.MatchString(dataset) {
|
||
return nil, errors.New("bad dataset name")
|
||
}
|
||
out, code, err := m.run(ctx, "zfs", "list", "-t", "snapshot", "-Hp", "-d", "1", "-o", "name,used,creation", "-s", "creation", dataset)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if code != 0 {
|
||
return nil, errors.New("zfs list failed")
|
||
}
|
||
snaps := []Snapshot{}
|
||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||
f := strings.Split(line, "\t")
|
||
if len(f) < 3 {
|
||
continue
|
||
}
|
||
c, _ := strconv.ParseInt(f[2], 10, 64)
|
||
snaps = append(snaps, Snapshot{Name: f[0], Used: u64(f[1]), Created: c * 1000})
|
||
}
|
||
return snaps, nil
|
||
}
|
||
|
||
// Scrub starts, pauses or stops a scrub. action: start | pause | stop.
|
||
func (m *Manager) Scrub(ctx context.Context, pool, action string) error {
|
||
if !poolRe.MatchString(pool) {
|
||
return errors.New("bad pool name")
|
||
}
|
||
args := []string{"zpool", "scrub"}
|
||
switch action {
|
||
case "start":
|
||
case "pause":
|
||
args = append(args, "-p")
|
||
case "stop":
|
||
args = append(args, "-s")
|
||
default:
|
||
return errors.New("unknown scrub action")
|
||
}
|
||
out, code, err := m.run(ctx, append(args, pool)...)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if code != 0 {
|
||
return errors.New(strings.TrimSpace(string(out)))
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// CreateSnapshot takes dataset@name.
|
||
func (m *Manager) CreateSnapshot(ctx context.Context, dataset, name string) error {
|
||
if !datasetRe.MatchString(dataset) || !snapRe.MatchString(name) {
|
||
return errors.New("bad snapshot name")
|
||
}
|
||
out, code, err := m.run(ctx, "zfs", "snapshot", dataset+"@"+name)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if code != 0 {
|
||
return errors.New(strings.TrimSpace(string(out)))
|
||
}
|
||
return nil
|
||
}
|