Files
mesh-catalog/modules/zfs/cmd/zfs-tools/health_test.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

114 lines
4.2 KiB
Go

package main
import (
"bytes"
"context"
"strings"
"testing"
"time"
)
func TestOnlinePoolsWithoutErrorsAreHealthyAndAnOldScrubIsOnlyAWarning(t *testing.T) {
h := withZFS(t).machine().Health(context.Background())
if !h.Healthy || len(h.Found) != 0 {
t.Fatalf("health: %+v", h)
}
if len(h.Warnings) != 1 || !strings.Contains(h.Warnings[0], "Pool rpool was last scrubbed 89 days ago, more than 35") {
t.Errorf("rpool's July scrub is a warning; tank's September one is not: %v", h.Warnings)
}
if !strings.Contains(h.Why, "tank ONLINE, 60 % full") {
t.Errorf("why: %s", h.Why)
}
}
func TestADegradedFullPoolIsUnhealthyWithEveryReason(t *testing.T) {
f := newFake(t)
f.on(line("zpool", PoolsArgv()), fixture(t, "zpool-list-full.txt"))
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt"))
h := f.machine().Health(context.Background())
if h.Healthy {
t.Fatalf("healthy: %+v", h)
}
for _, want := range []string{
"Pool tank is DEGRADED: One or more devices is currently being resilvered",
"Action: Wait for the resilver to complete",
"Pool tank is 91 % full, past 90 %",
"device ata-WDC_WD120EFBX-68B0EN0_D7JA0002 in pool tank has errors: read 12, write 107, checksum 3",
"device ata-WDC_WD120EFBX-68B0EN0_D7JA0002 in pool tank is FAULTED",
"Pool tank: 2 data errors",
} {
if !strings.Contains(h.Why, want) {
t.Errorf("why lacks %q: %s", want, h.Why)
}
}
if strings.Contains(h.Why, "D7JA0009 in pool tank is") {
t.Errorf("a spare in use is not a finding: %s", h.Why)
}
}
func TestCapacityPastEightyIsAWarningAndPastNinetyUnhealthy(t *testing.T) {
st, _ := ParseStatus(fixture(t, "zpool-status-scrubbing.txt"))
now := newFake(t).now
p := Pool{Name: "tank", Capacity: 85, Health: "ONLINE"}
if h := Judge([]Pool{p}, st, now); !h.Healthy || len(h.Warnings) != 1 || !strings.Contains(h.Warnings[0], "85 % full, past 80 %") {
t.Errorf("85 %%: %+v", h)
}
p.Capacity = 80
if h := Judge([]Pool{p}, st, now); !h.Healthy || len(h.Warnings) != 0 {
t.Errorf("80 %% is not past 80: %+v", h)
}
p.Capacity = 92
if h := Judge([]Pool{p}, st, now); h.Healthy {
t.Errorf("92 %%: %+v", h)
}
}
func TestFaultedAndNoPoolAreUnhealthy(t *testing.T) {
now := newFake(t).now
if h := Judge(nil, nil, now); h.Healthy || !strings.Contains(h.Why, "no pool is imported") {
t.Errorf("no pool: %+v", h)
}
st := []PoolStatus{{Name: "tank", State: "FAULTED", Status: "One or more devices could not be opened.", Errors: "No known data errors",
Root: &Device{Name: "tank", Kind: "pool", State: "FAULTED"}}}
if h := Judge([]Pool{{Name: "tank", Health: "FAULTED"}}, st, now); h.Healthy || !strings.Contains(h.Why, "Pool tank is FAULTED: One or more devices could not be opened") {
t.Errorf("faulted: %+v", h)
}
}
func TestTheWatchSaysOnlyChanges(t *testing.T) {
f := withZFS(t)
var out bytes.Buffer
w := &Watch{M: f.machine(), Out: &out}
w.Look(context.Background())
w.Look(context.Background())
if n := strings.Count(out.String(), "\n"); n != 1 || !strings.Contains(out.String(), "healthy: Every pool is online") {
t.Errorf("one line for one state: %q", out.String())
}
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt"))
w.Look(context.Background())
if !strings.Contains(out.String(), "UNHEALTHY: ") {
t.Errorf("the change is said: %q", out.String())
}
}
func TestTheFollowerWritesEachNotableEventFromItsStartOnce(t *testing.T) {
var out bytes.Buffer
since := time.Date(2026, 10, 8, 21, 0, 0, 0, time.Local)
if err := Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, since); err != nil {
t.Fatal(err)
}
got := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(got) != 3 || !strings.Contains(got[0], "ereport.fs.zfs.io") || !strings.Contains(got[1], "vdev_state=FAULTED") ||
!strings.Contains(got[2], "resilver_start") {
t.Errorf("lines: %q", got)
}
out.Reset()
Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, time.Date(2026, 10, 8, 21, 2, 10, 0, time.Local))
if got := strings.TrimSpace(out.String()); strings.Count(got, "\n") != 0 || !strings.Contains(got, "resilver_start") {
t.Errorf("only what came after since: %q", got)
}
if strings.Join(FollowArgv(), " ") != "events -f -v" {
t.Errorf("argv: %v", FollowArgv())
}
}