Files
mesh-catalog/modules/zfs/cmd/zfs-tools/pools.go
T
jochen 7c8497225d
mesh/merge-gate pass: builds new: modules/zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded (0.00s)
mesh/delivery superseded: a newer head of the same pull request
Add the zfs module, so the mesh can see the home server's pools and snapshot a dataset
The home server's pool holds the operator's media library, and no mesh tool could say whether it is
healthy, how full it is, when it was last scrubbed or what snapshots exist; the only way was a login.
This module reads all of it with fixed zfs and zpool commands (list, get, status, iostat, events,
history) and has one act, zfs_snapshot, which only adds a snapshot: refused for an unknown dataset, a
name outside [A-Za-z0-9._-], an existing snapshot or no why; run as sudo -n zfs snapshot -- ds@name;
answered from zfs list afterwards, with any other snapshot that came or went meanwhile; recorded as the
event snapshot.taken. No destroy, rollback, set or send exists, and a test holds every command to its
verb.

Health (ADR 0240): zfs_health on the watch process, unhealthy for a pool not ONLINE, device or data
errors, a pool past 90 % full or no ZFS at all; past 80 % and a scrub older than 35 days are warnings.
Beside it, the event follower (zpool events -f, as root) keeps ZFS's errors in its journal past a
reboot and is judged by its unit, the check the module does not run itself. No seat (ADR 0121), no
package: ZFS is the machine's.
2026-10-09 00:11:31 +02:00

567 lines
16 KiB
Go

package main
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
// PoolColumns are the properties `zpool list` is asked for, in order.
var PoolColumns = []string{"name", "size", "allocated", "free", "capacity", "fragmentation", "health", "dedupratio"}
// PoolsArgv lists every imported pool.
func PoolsArgv() []string {
return []string{"list", "-Hp", "-o", strings.Join(PoolColumns, ",")}
}
// StatusArgv is every imported pool's status, numbers exact.
func StatusArgv() []string { return []string{"status", "-p"} }
// Pool is one pool as `zpool list` answers it.
type Pool struct {
Name string `json:"name"`
Size uint64 `json:"size"`
Allocated uint64 `json:"allocated"`
Free uint64 `json:"free"`
// Capacity is the share of the pool allocated, in %.
Capacity int `json:"capacity_pct"`
// Fragmentation is the free space's fragmentation in %, nil when the pool cannot say.
Fragmentation *int `json:"fragmentation_pct,omitempty"`
Health string `json:"health"`
DedupRatio float64 `json:"dedupratio"`
}
// ParsePools reads `zpool list -Hp -o <PoolColumns>`.
func ParsePools(out string) ([]Pool, error) {
var ps []Pool
for _, line := range lines(out) {
f := strings.Split(line, "\t")
if len(f) != len(PoolColumns) {
return nil, fmt.Errorf("zpool list answered a line with %d columns, not %d: %q", len(f), len(PoolColumns), line)
}
p := Pool{Name: f[0], Health: f[6]}
var err error
if p.Size, err = number(f[1]); err != nil {
return nil, fmt.Errorf("zpool list: size of %s: %v", p.Name, err)
}
if p.Allocated, err = number(f[2]); err != nil {
return nil, fmt.Errorf("zpool list: allocated of %s: %v", p.Name, err)
}
if p.Free, err = number(f[3]); err != nil {
return nil, fmt.Errorf("zpool list: free of %s: %v", p.Name, err)
}
if p.Capacity, err = strconv.Atoi(strings.TrimSuffix(f[4], "%")); err != nil {
return nil, fmt.Errorf("zpool list: capacity of %s is %q", p.Name, f[4])
}
if fr, e := strconv.Atoi(strings.TrimSuffix(f[5], "%")); e == nil {
p.Fragmentation = &fr
}
if p.DedupRatio, err = ratio(f[7]); err != nil {
return nil, fmt.Errorf("zpool list: dedupratio of %s is %q", p.Name, f[7])
}
ps = append(ps, p)
}
return ps, nil
}
// PoolRow is one pool's space as the tools answer it.
type PoolRow struct {
Name string `json:"name"`
Health string `json:"health"`
Size uint64 `json:"size_bytes"`
Allocated uint64 `json:"allocated_bytes"`
Free uint64 `json:"free_bytes"`
Capacity int `json:"capacity_pct"`
Fragmentation *int `json:"fragmentation_pct,omitempty"`
Said string `json:"said"`
}
// Row says a pool's space in a line.
func (p Pool) Row() PoolRow {
r := PoolRow{Name: p.Name, Health: p.Health, Size: p.Size, Allocated: p.Allocated, Free: p.Free, Capacity: p.Capacity,
Fragmentation: p.Fragmentation}
frag := "fragmentation not known"
if p.Fragmentation != nil {
frag = fmt.Sprintf("%d %% fragmented", *p.Fragmentation)
}
r.Said = fmt.Sprintf("pool %s (%s): %s of %s allocated (%d %%), %s free, %s", p.Name, p.Health, bytesSaid(p.Allocated),
bytesSaid(p.Size), p.Capacity, bytesSaid(p.Free), frag)
return r
}
// Pools are every imported pool.
func (m *Machine) Pools(ctx context.Context) ([]Pool, error) {
out, err := m.ask(ctx, "zpool", PoolsArgv()...)
if err != nil {
return nil, err
}
return ParsePools(out)
}
// Device is one line of a pool's configuration: the pool itself, a vdev (mirror, raidz), a disk, or a
// section (logs, cache, spares, special, dedup), with its children.
type Device struct {
Name string `json:"name"`
// Kind is pool, vdev (a group of disks), device (a disk or a partition), or section.
Kind string `json:"kind"`
// Role is data, log, cache, spare, special or dedup: the section a device is in.
Role string `json:"role,omitempty"`
State string `json:"state,omitempty"`
Read uint64 `json:"read_errors"`
Write uint64 `json:"write_errors"`
Checksum uint64 `json:"checksum_errors"`
Note string `json:"note,omitempty"`
Children []*Device `json:"children,omitempty"`
}
// Errors is the error count status gives this line, read, write and checksum together: a vdev's line
// counts what was found at the vdev, not the sum of its disks.
func (d *Device) Errors() uint64 { return d.Read + d.Write + d.Checksum }
// Scan is a pool's last scan, as status says it: ZFS keeps only the latest, a scrub's or a resilver's.
type Scan struct {
// Kind is scrub or resilver; "" when none was ever asked.
Kind string `json:"kind,omitempty"`
// State is finished, running, paused, canceled or none.
State string `json:"state"`
Started *time.Time `json:"started,omitempty"`
Ended *time.Time `json:"ended,omitempty"`
Took string `json:"took,omitempty"`
Repaired string `json:"repaired,omitempty"`
Errors *int `json:"errors,omitempty"`
Done *float64 `json:"done_pct,omitempty"`
ToGo string `json:"to_go,omitempty"`
Said string `json:"said"`
}
// PoolStatus is one pool as `zpool status` says it.
type PoolStatus struct {
Name string `json:"name"`
State string `json:"state"`
Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"`
See string `json:"see,omitempty"`
Scan Scan `json:"scan"`
// Other are the lines status gives for a removal, a checkpoint or an expansion.
Other map[string]string `json:"other,omitempty"`
Root *Device `json:"layout,omitempty"`
Errors string `json:"errors"`
}
var sections = map[string]string{"logs": "log", "cache": "cache", "spares": "spare", "special": "special", "dedup": "dedup"}
// ParseStatus reads `zpool status -p` for every pool it shows.
func ParseStatus(out string) ([]PoolStatus, error) {
var pools []PoolStatus
var cur *PoolStatus
key := ""
inConfig := false
var stack []*Device
section := ""
flush := func() {
if cur != nil {
cur.Scan = parseScan(cur.Scan.Said)
pools = append(pools, *cur)
}
}
for _, raw := range strings.Split(out, "\n") {
line := strings.TrimRight(raw, "\r ")
trimmed := strings.TrimSpace(line)
if k, v, ok := statusKey(line); ok {
inConfig = false
key = k
switch k {
case "pool":
flush()
cur = &PoolStatus{Name: v}
stack, section = nil, ""
case "config":
inConfig = true
}
if cur == nil {
continue
}
switch k {
case "state":
cur.State = v
case "status":
cur.Status = v
case "action":
cur.Action = v
case "see":
cur.See = v
case "scan":
cur.Scan.Said = v
case "errors":
cur.Errors = v
case "pool", "config":
default:
if cur.Other == nil {
cur.Other = map[string]string{}
}
cur.Other[k] = v
}
continue
}
if cur == nil || trimmed == "" {
continue
}
if !inConfig {
// A continuation of the key before it, indented by a tab.
add := func(s *string) { *s = strings.TrimSpace(*s + " " + trimmed) }
switch key {
case "status":
add(&cur.Status)
case "action":
add(&cur.Action)
case "scan":
cur.Scan.Said += "\n" + trimmed
case "errors":
add(&cur.Errors)
default:
if cur.Other != nil {
if v, ok := cur.Other[key]; ok {
cur.Other[key] = strings.TrimSpace(v + " " + trimmed)
}
}
}
continue
}
// The configuration: a tab, two spaces per level, then the columns.
body := strings.TrimPrefix(line, "\t")
depth := (len(body) - len(strings.TrimLeft(body, " "))) / 2
f := strings.Fields(body)
if f[0] == "NAME" && len(f) > 1 && f[1] == "STATE" {
continue
}
d := &Device{Name: f[0]}
if depth == 0 {
if role, ok := sections[f[0]]; ok && len(f) == 1 {
d.Kind, d.Role, section = "section", role, role
if cur.Root != nil {
cur.Root.Children = append(cur.Root.Children, d)
}
// A section stands at the pool's level and holds what is indented under it.
stack = []*Device{d}
continue
}
}
if len(f) > 1 {
d.State = f[1]
}
rest := f[min(len(f), 2):]
if len(rest) >= 3 && isCount(rest[0]) && isCount(rest[1]) && isCount(rest[2]) {
d.Read, d.Write, d.Checksum = count(rest[0]), count(rest[1]), count(rest[2])
rest = rest[3:]
}
d.Note = strings.Join(rest, " ")
if depth == 0 {
d.Kind, d.Role = "pool", ""
cur.Root = d
stack, section = []*Device{d}, ""
continue
}
role := section
if role == "" {
role = "data"
}
d.Role = role
if depth < len(stack) {
stack = stack[:depth]
}
if len(stack) == 0 {
return nil, fmt.Errorf("zpool status: the device %s of pool %s has no parent", d.Name, cur.Name)
}
parent := stack[len(stack)-1]
parent.Children = append(parent.Children, d)
stack = append(stack, d)
}
flush()
for i := range pools {
kinds(pools[i].Root)
}
return pools, nil
}
// kinds names each device by its place: a device with children is a vdev, one without a device.
func kinds(d *Device) {
if d == nil {
return
}
for _, c := range d.Children {
if c.Kind == "" {
if len(c.Children) > 0 {
c.Kind = "vdev"
} else {
c.Kind = "device"
}
}
kinds(c)
}
}
var keyLine = regexp.MustCompile(`^\s{0,8}([a-z]+):(?:\s(.*))?$`)
// statusKey is a `key: value` line of status: pool, state, status, action, see, scan, config, errors, …
func statusKey(line string) (string, string, bool) {
if strings.HasPrefix(line, "\t") {
return "", "", false
}
m := keyLine.FindStringSubmatch(line)
if m == nil {
return "", "", false
}
return m[1], strings.TrimSpace(m[2]), true
}
func isCount(s string) bool {
if s == "" {
return false
}
_, err := strconv.ParseFloat(strings.TrimRight(s, "KMGTP"), 64)
return err == nil
}
// count is an error count: exact under -p, else 1.2K and the like.
func count(s string) uint64 {
mult := 1.0
switch s[len(s)-1] {
case 'K':
mult = 1e3
case 'M':
mult = 1e6
case 'G':
mult = 1e9
case 'T':
mult = 1e12
case 'P':
mult = 1e15
}
f, _ := strconv.ParseFloat(strings.TrimRight(s, "KMGTP"), 64)
return uint64(f * mult)
}
var (
scanFinished = regexp.MustCompile(`^(scrub repaired|resilvered) (\S+) in (.+?) with (\d+) errors on (.+)$`)
scanRunning = regexp.MustCompile(`^(scrub|resilver) in progress since (.+)$`)
scanPaused = regexp.MustCompile(`^(scrub|resilver) paused since (.+)$`)
scanCanceled = regexp.MustCompile(`^(scrub|resilver) canceled on (.+)$`)
scanDone = regexp.MustCompile(`([\d.]+)% done`)
scanToGo = regexp.MustCompile(`(\d+ days \d+:\d+:\d+|\d+:\d+:\d+) to go`)
scanRepaired = regexp.MustCompile(`(\S+) repaired`)
)
// parseScan reads status's scan line and its continuation.
func parseScan(said string) Scan {
s := Scan{State: "none", Said: said}
first, more, _ := strings.Cut(said, "\n")
first = strings.TrimSpace(first)
switch {
case first == "" || first == "none requested":
return s
case scanFinished.MatchString(first):
m := scanFinished.FindStringSubmatch(first)
s.Kind, s.State, s.Repaired, s.Took = "scrub", "finished", m[2], m[3]
if m[1] == "resilvered" {
s.Kind = "resilver"
}
n, _ := strconv.Atoi(m[4])
s.Errors = &n
s.Ended = when(m[5])
if s.Ended != nil {
if d, ok := took(m[3]); ok {
st := s.Ended.Add(-d)
s.Started = &st
}
}
case scanRunning.MatchString(first):
m := scanRunning.FindStringSubmatch(first)
s.Kind, s.State, s.Started = m[1], "running", when(m[2])
case scanPaused.MatchString(first):
m := scanPaused.FindStringSubmatch(first)
s.Kind, s.State, s.Started = m[1], "paused", when(m[2])
case scanCanceled.MatchString(first):
m := scanCanceled.FindStringSubmatch(first)
s.Kind, s.State, s.Ended = m[1], "canceled", when(m[2])
default:
s.State = "unknown"
}
if more != "" {
if m := scanDone.FindStringSubmatch(more); m != nil {
v, _ := strconv.ParseFloat(m[1], 64)
s.Done = &v
}
if m := scanToGo.FindStringSubmatch(more); m != nil {
s.ToGo = m[1]
}
if m := scanRepaired.FindStringSubmatch(more); m != nil && s.Repaired == "" {
s.Repaired = m[1]
}
}
return s
}
// when reads status's dates, as ctime writes them: "Sun Sep 13 05:36:34 2026", in local time.
func when(s string) *time.Time {
s = strings.TrimSpace(s)
for _, layout := range []string{time.ANSIC, "Mon Jan 2 15:04:05 2006"} {
if t, err := time.ParseInLocation(layout, s, time.Local); err == nil {
return &t
}
}
return nil
}
// took reads a scan's duration: "05:12:33" or "1 days 05:12:33".
func took(s string) (time.Duration, bool) {
days := 0
if d, rest, ok := strings.Cut(s, " days "); ok {
n, err := strconv.Atoi(d)
if err != nil {
return 0, false
}
days, s = n, rest
}
p := strings.Split(s, ":")
if len(p) != 3 {
return 0, false
}
var v [3]int
for i := range p {
n, err := strconv.Atoi(p[i])
if err != nil {
return 0, false
}
v[i] = n
}
return time.Duration(days)*24*time.Hour + time.Duration(v[0])*time.Hour + time.Duration(v[1])*time.Minute +
time.Duration(v[2])*time.Second, true
}
// Status is every pool's status.
func (m *Machine) Status(ctx context.Context) ([]PoolStatus, error) {
out, err := m.ask(ctx, "zpool", StatusArgv()...)
if err != nil {
return nil, err
}
return ParseStatus(out)
}
// PoolsAnswer is what zfs_pools answers.
type PoolsAnswer struct {
Applicable bool `json:"applicable"`
Pools []PoolHealth `json:"pools"`
Said []string `json:"said"`
}
// PoolHealth is one pool's health and space.
type PoolHealth struct {
PoolRow
State string `json:"state"`
Status string `json:"status,omitempty"`
Action string `json:"action,omitempty"`
Errors string `json:"errors"`
DeviceErrs uint64 `json:"device_errors"`
Scan string `json:"last_scan"`
}
// PoolsNow is every pool's health in a line, with its space.
func (m *Machine) PoolsNow(ctx context.Context) (any, error) {
ps, err := m.Pools(ctx)
if err != nil {
return notHere(err)
}
st, err := m.Status(ctx)
if err != nil {
return nil, err
}
a := PoolsAnswer{Applicable: true, Pools: []PoolHealth{}, Said: []string{}}
for _, p := range ps {
h := PoolHealth{PoolRow: p.Row(), State: p.Health}
if s := statusOf(st, p.Name); s != nil {
h.State, h.Status, h.Action, h.Errors, h.Scan = s.State, s.Status, s.Action, s.Errors, firstLine(s.Scan.Said)
h.DeviceErrs = sumErrors(s.Root)
}
line := h.Said + fmt.Sprintf("; %d device errors; errors: %s", h.DeviceErrs, h.Errors)
if h.Status != "" {
line += "; status: " + h.Status
}
a.Pools = append(a.Pools, h)
a.Said = append(a.Said, line)
}
if len(ps) == 0 {
a.Said = append(a.Said, "ZFS is here, and no pool is imported.")
}
return a, nil
}
// LayoutAnswer is what zfs_pool_layout answers.
type LayoutAnswer struct {
Applicable bool `json:"applicable"`
Pools []PoolStatus `json:"pools"`
Said []string `json:"said"`
}
// Layout is every pool's vdevs and devices with their state and errors.
func (m *Machine) Layout(ctx context.Context) (any, error) {
st, err := m.Status(ctx)
if err != nil {
return notHere(err)
}
a := LayoutAnswer{Applicable: true, Pools: st, Said: []string{}}
for _, p := range st {
a.Said = append(a.Said, fmt.Sprintf("pool %s: %s", p.Name, p.State))
walk(p.Root, func(d *Device, depth int) {
if d.Kind == "pool" {
return
}
line := strings.Repeat(" ", depth) + d.Name
if d.Kind == "section" {
a.Said = append(a.Said, line+" ("+d.Role+")")
return
}
line += " " + d.State
if d.Errors() > 0 {
line += fmt.Sprintf(" — ERRORS read %d, write %d, checksum %d", d.Read, d.Write, d.Checksum)
}
if d.Note != "" {
line += " " + d.Note
}
a.Said = append(a.Said, line)
}, 0)
}
return a, nil
}
func walk(d *Device, f func(*Device, int), depth int) {
if d == nil {
return
}
f(d, depth)
for _, c := range d.Children {
walk(c, f, depth+1)
}
}
func sumErrors(d *Device) uint64 {
var n uint64
walk(d, func(x *Device, _ int) {
if x.Kind != "section" {
n += x.Errors()
}
}, 0)
return n
}
func statusOf(st []PoolStatus, name string) *PoolStatus {
for i := range st {
if st[i].Name == name {
return &st[i]
}
}
return nil
}