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
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.
222 lines
6.7 KiB
Go
222 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// IOArgv samples every pool's I/O for some seconds: -y leaves out the report since boot, so the one report
|
|
// is the window alone.
|
|
func IOArgv(seconds int) []string {
|
|
return []string{"iostat", "-y", "-Hpv", strconv.Itoa(seconds), "1"}
|
|
}
|
|
|
|
// IORow is one line of the sample: a pool, a vdev, a device or a section.
|
|
type IORow struct {
|
|
Name string `json:"name"`
|
|
Pool string `json:"pool"`
|
|
// Kind is pool, vdev, device or section, from the pool's layout.
|
|
Kind string `json:"kind"`
|
|
Allocated *uint64 `json:"allocated_bytes,omitempty"`
|
|
Free *uint64 `json:"free_bytes,omitempty"`
|
|
ReadOps float64 `json:"read_ops_per_s"`
|
|
WriteOps float64 `json:"write_ops_per_s"`
|
|
ReadBytes float64 `json:"read_bytes_per_s"`
|
|
WriteBytes float64 `json:"write_bytes_per_s"`
|
|
}
|
|
|
|
// ParseIO reads `zpool iostat -Hpv`: name, allocated, free, read and write operations, read and write
|
|
// bandwidth, tab-separated; "-" where a line has no such number. Lines of a pool follow the pool's own
|
|
// line; layout names each line's kind.
|
|
func ParseIO(out string, layout []PoolStatus) ([]IORow, error) {
|
|
var rows []IORow
|
|
pool := ""
|
|
for _, line := range lines(out) {
|
|
f := strings.Split(line, "\t")
|
|
if len(f) != 7 {
|
|
f = strings.Fields(line)
|
|
}
|
|
if len(f) != 7 {
|
|
return nil, fmt.Errorf("zpool iostat answered a line with %d columns, not 7: %q", len(f), line)
|
|
}
|
|
r := IORow{Name: strings.TrimSpace(f[0])}
|
|
if statusOf(layout, r.Name) != nil {
|
|
pool = r.Name
|
|
r.Kind = "pool"
|
|
}
|
|
r.Pool = pool
|
|
if r.Kind == "" {
|
|
r.Kind = "device"
|
|
if _, ok := sections[r.Name]; ok && allDash(f[1:]) {
|
|
r.Kind = "section"
|
|
} else if p := statusOf(layout, pool); p != nil {
|
|
walk(p.Root, func(d *Device, _ int) {
|
|
if d.Name == r.Name && d.Kind != "pool" {
|
|
r.Kind = d.Kind
|
|
}
|
|
}, 0)
|
|
}
|
|
}
|
|
r.Allocated, r.Free = optionalNumber(f[1]), optionalNumber(f[2])
|
|
vals := []*float64{&r.ReadOps, &r.WriteOps, &r.ReadBytes, &r.WriteBytes}
|
|
for i, v := range vals {
|
|
s := f[3+i]
|
|
if s == "-" {
|
|
continue
|
|
}
|
|
x, err := strconv.ParseFloat(s, 64)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("zpool iostat: %q of %s is not a number", s, r.Name)
|
|
}
|
|
*v = x
|
|
}
|
|
rows = append(rows, r)
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func allDash(f []string) bool {
|
|
for _, s := range f {
|
|
if strings.TrimSpace(s) != "-" {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func optionalNumber(s string) *uint64 {
|
|
if s == "-" {
|
|
return nil
|
|
}
|
|
v, err := strconv.ParseUint(s, 10, 64)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &v
|
|
}
|
|
|
|
// IOAnswer is what zfs_io answers.
|
|
type IOAnswer struct {
|
|
Applicable bool `json:"applicable"`
|
|
Seconds int `json:"window_s"`
|
|
Rows []IORow `json:"rows"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
// IO samples every pool's operations and bandwidth for a few seconds.
|
|
func (m *Machine) IO(ctx context.Context, seconds int) (any, error) {
|
|
st, err := m.Status(ctx)
|
|
if err != nil {
|
|
return notHere(err)
|
|
}
|
|
sctx, cancel := context.WithTimeout(ctx, time.Duration(seconds)*time.Second+CommandTimeout)
|
|
defer cancel()
|
|
out, err := m.ask(sctx, "zpool", IOArgv(seconds)...)
|
|
if err != nil {
|
|
return notHere(err)
|
|
}
|
|
rows, err := ParseIO(out, st)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
a := IOAnswer{Applicable: true, Seconds: seconds, Rows: rows, Said: []string{}}
|
|
for _, r := range rows {
|
|
if r.Kind == "section" {
|
|
a.Said = append(a.Said, r.Pool+" "+r.Name+":")
|
|
continue
|
|
}
|
|
a.Said = append(a.Said, fmt.Sprintf("%s %s (%s): read %.0f ops/s %s/s, write %.0f ops/s %s/s", r.Pool, r.Name, r.Kind,
|
|
r.ReadOps, bytesSaid(uint64(r.ReadBytes)), r.WriteOps, bytesSaid(uint64(r.WriteBytes))))
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
// ArcStats is where the kernel module keeps the ARC's counters.
|
|
const ArcStats = "/proc/spl/kstat/zfs/arcstats"
|
|
|
|
// ParseArc reads arcstats: two header lines, then `name type value`.
|
|
func ParseArc(text string) map[string]uint64 {
|
|
out := map[string]uint64{}
|
|
for i, line := range strings.Split(text, "\n") {
|
|
f := strings.Fields(line)
|
|
if i < 2 || len(f) != 3 {
|
|
continue
|
|
}
|
|
if v, err := strconv.ParseUint(f[2], 10, 64); err == nil {
|
|
out[f[0]] = v
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ArcAnswer is what zfs_arc answers.
|
|
type ArcAnswer struct {
|
|
Applicable bool `json:"applicable"`
|
|
Size uint64 `json:"size_bytes"`
|
|
Target uint64 `json:"target_bytes"`
|
|
Min uint64 `json:"min_bytes"`
|
|
Max uint64 `json:"max_bytes"`
|
|
Hits uint64 `json:"hits"`
|
|
Misses uint64 `json:"misses"`
|
|
HitRatio float64 `json:"hit_ratio_pct"`
|
|
// DemandHitRatio counts only what was asked for, not what ZFS read ahead.
|
|
DemandHitRatio *float64 `json:"demand_hit_ratio_pct,omitempty"`
|
|
L2 *L2Arc `json:"l2arc,omitempty"`
|
|
Said []string `json:"said"`
|
|
}
|
|
|
|
// L2Arc is the cache device's share of the ARC, where the pool has one.
|
|
type L2Arc struct {
|
|
Size uint64 `json:"size_bytes"`
|
|
Allocated uint64 `json:"allocated_bytes"`
|
|
Hits uint64 `json:"hits"`
|
|
Misses uint64 `json:"misses"`
|
|
HitRatio float64 `json:"hit_ratio_pct"`
|
|
}
|
|
|
|
// Arc is the ARC's size and hit ratio since the machine started.
|
|
func (m *Machine) Arc() (any, error) {
|
|
raw, err := m.ReadFile(ArcStats)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return NotApplicable{Applicable: false, Said: "there is no " + ArcStats + ": ZFS's kernel module is not loaded on this machine, so this module does not apply here"}, nil
|
|
}
|
|
return nil, fmt.Errorf("%s could not be read: %v", ArcStats, err)
|
|
}
|
|
s := ParseArc(string(raw))
|
|
if _, ok := s["size"]; !ok {
|
|
return nil, fmt.Errorf("%s has no size line", ArcStats)
|
|
}
|
|
a := ArcAnswer{Applicable: true, Size: s["size"], Target: s["c"], Min: s["c_min"], Max: s["c_max"], Hits: s["hits"], Misses: s["misses"]}
|
|
a.HitRatio = pct(a.Hits, a.Hits+a.Misses)
|
|
dh := s["demand_data_hits"] + s["demand_metadata_hits"]
|
|
dm := s["demand_data_misses"] + s["demand_metadata_misses"]
|
|
if dh+dm > 0 {
|
|
r := pct(dh, dh+dm)
|
|
a.DemandHitRatio = &r
|
|
}
|
|
a.Said = []string{fmt.Sprintf("ARC: %s of a %s target (min %s, max %s); %.1f %% of %d reads since the machine started were hits",
|
|
bytesSaid(a.Size), bytesSaid(a.Target), bytesSaid(a.Min), bytesSaid(a.Max), a.HitRatio, a.Hits+a.Misses)}
|
|
if s["l2_size"] > 0 || s["l2_hits"]+s["l2_misses"] > 0 {
|
|
l := &L2Arc{Size: s["l2_size"], Allocated: s["l2_asize"], Hits: s["l2_hits"], Misses: s["l2_misses"]}
|
|
l.HitRatio = pct(l.Hits, l.Hits+l.Misses)
|
|
a.L2 = l
|
|
a.Said = append(a.Said, fmt.Sprintf("L2ARC: %s cached (%s on the device), %.1f %% of %d reads were hits",
|
|
bytesSaid(l.Size), bytesSaid(l.Allocated), l.HitRatio, l.Hits+l.Misses))
|
|
} else {
|
|
a.Said = append(a.Said, "no L2ARC (no cache device)")
|
|
}
|
|
return a, nil
|
|
}
|
|
|
|
func pct(part, whole uint64) float64 {
|
|
if whole == 0 {
|
|
return 0
|
|
}
|
|
return round1(float64(part) * 100 / float64(whole))
|
|
}
|