Files
mesh-catalog/modules/zfs/cmd/zfs-tools/review_test.go
T
jochen bd0182785c
mesh/merge-gate pass: builds zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check pass: its merge-check.sh passed
mesh/delivery superseded: a newer head of the same pull request
Run the scrub schedule as a scheduled process, never pause a scrub, and keep the pool's name the only setting in a unit
The second review of #147 found three blockers before the home server.

1. ${setting:} was not filled in a unit's name, so the distribution timers would have reached the
   machine as "zfs-scrub-weekly@${setting:scrub-pool}.timer". mesh-controller #171 fills it and refuses
   any value that is not a unit-name part. The module's own schedule needs no setting in a name now: the
   node-engine's timer for the scheduled process zfs-mesh-scrub fires `zfs-tools scrub-run` every quarter
   of an hour, and the run reads the pool, cadence, day and time from the configuration file.
2. The run does what upstream's zfs-scrub@.service does: if a scrub is running it waits for it with
   zpool wait -t scrub, otherwise it starts one and waits. It never pauses or stops a scrub, and the unit
   has no ExecStop. A resilver defers the scrub. The served slot is kept in /var/lib/zfs-tools.
3. Unassigning left the pool with no schedule. mesh-host #66 (hq ADR 0269, proposed) gives the
   distribution timer back running and enabled. The README says not to unassign before it is merged.

Also: the run comes before the distribution timers in the declaration; the day and time are checked
against fixed sets and reach no unit file (an invalid value runs nothing); a due scrub not started an
hour after its time is a warning; the error counts outlive a restart in a small state file.
2026-10-09 01:40:02 +02:00

340 lines
13 KiB
Go

package main
// The safety review of the first version (mesh-catalog 90516fc) and the operator's scrub requirement:
// each test here failed against that version, or did not compile against it.
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// A command that does not answer in time is ended with every process it started: the runner puts it in a
// process group of its own and signals the group, so a child holding the output open (as zfs under sudo
// does) cannot keep the call waiting.
func TestATimedOutCommandIsEndedWithItsChildren(t *testing.T) {
old := KillGrace
KillGrace = 300 * time.Millisecond
defer func() { KillGrace = old }()
pidFile := filepath.Join(t.TempDir(), "child")
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
r := ExecRunner(ctx, "sh", "-c", "sleep 30 & echo $! > "+pidFile+"; wait")
if took := time.Since(start); took > 5*time.Second {
t.Fatalf("the call waited %s for a child holding its output", took)
}
if !r.TimedOut {
t.Errorf("not said as timed out: %+v", r)
}
b, err := os.ReadFile(pidFile)
if err != nil {
t.Fatal(err)
}
pid, _ := strconv.Atoi(strings.TrimSpace(string(b)))
time.Sleep(100 * time.Millisecond)
if err := syscall.Kill(pid, 0); err == nil {
syscall.Kill(pid, syscall.SIGKILL)
t.Errorf("the child %d outlived the timeout", pid)
}
}
func TestTheSnapshotHasNoRecursiveAndSaysWhatItWillHold(t *testing.T) {
f := withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t26938034880512")
m := f.machine()
var take func(map[string]any) (any, error)
for _, tl := range Tools(m) {
if tl.Name == "zfs_snapshot" {
take = tl.Run
if _, has := tl.Input["recursive"]; has {
t.Errorf("zfs_snapshot still offers recursive")
}
}
}
if _, err := take(map[string]any{"dataset": "tank", "name": "x", "why": "y", "recursive": true}); err == nil ||
!strings.Contains(err.Error(), "recursive") {
t.Errorf("a recursive argument is refused, not ignored: %v", err)
}
if n := len(ran(f, "sudo")); n != 0 {
t.Errorf("the refusal ran %v", f.calls)
}
got, err := take(map[string]any{"dataset": "tank/media", "name": "before-move", "why": "before moving the library"})
if err != nil {
t.Fatal(err)
}
a := got.(SnapshotAnswer)
if a.Available != 11203942195200 || a.PoolCapacity != 60 || a.Pool != "tank" {
t.Errorf("free space and capacity: %+v", a)
}
for _, want := range []string{"10.2 TiB free", "pool tank 60 % full",
"holds every block of tank/media deleted or overwritten from now on", "until it is destroyed by hand"} {
if !strings.Contains(a.Said, want) {
t.Errorf("said lacks %q: %s", want, a.Said)
}
}
}
func TestASnapshotThatTimedOutIsOutcomeUnknownUnlessTheListShowsIt(t *testing.T) {
f := withZFS(t)
f.answers[takeLine] = Ran{Code: -1, TimedOut: true, Err: "did not answer within its time"}
_, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err == nil || !strings.Contains(err.Error(), "outcome unknown; check zfs_snapshots") || strings.Contains(err.Error(), "NOT DONE") {
t.Errorf("timed out, not shown: %v", err)
}
f = withZFS(t)
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1")
f.answers[takeLine] = Ran{Code: -1, TimedOut: true}
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
if err != nil || !a.Done {
t.Errorf("timed out and shown by the list: done (%v, %+v)", err, a)
}
}
// The follower starts at the start of the kernel's queue, writes each notable event once across its own
// restarts, says what ZFS dropped, and records where it got to.
func TestTheFollowerWritesTheQueueFromItsStartOnceAndWhatWasDropped(t *testing.T) {
cur := &Cursor{}
var out bytes.Buffer
input := "dropped 2 events\n" + fixture(t, "zpool-events.txt")
if err := Follow(strings.NewReader(input), &out, cur); err != nil {
t.Fatal(err)
}
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
if len(lines) != 4 || !strings.Contains(lines[0], "ZFS dropped 2 events") || !strings.Contains(lines[1], "ereport.fs.zfs.io") ||
!strings.Contains(lines[3], "resilver_start") {
t.Fatalf("lines: %q", lines)
}
if cur.EID != 0x14 {
t.Errorf("the cursor is the last event read: %+v", cur)
}
// Restarted within the same boot: the queue is read again from its start, and nothing is written twice.
out.Reset()
if err := Follow(strings.NewReader(fixture(t, "zpool-events.txt")), &out, cur); err != nil {
t.Fatal(err)
}
if out.Len() != 0 {
t.Errorf("written twice: %q", out.String())
}
// ZFS's module reloaded: its counter starts again, and an event after the cursor's time is new.
reloaded := strings.ReplaceAll(fixture(t, "zpool-events.txt"), "Oct 8 2026", "Oct 9 2026")
out.Reset()
Follow(strings.NewReader(reloaded), &out, cur)
if strings.Count(out.String(), "\n") != 3 {
t.Errorf("after a reload: %q", out.String())
}
}
func TestTheCursorIsKeptPerBoot(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "cursor")
c := &Cursor{Boot: "a", EID: 7, Time: time.Date(2026, 10, 8, 21, 0, 0, 0, time.Local)}
if err := c.Save(path); err != nil {
t.Fatal(err)
}
if got := LoadCursor(path, "a"); got.EID != 7 || !got.Time.Equal(c.Time) {
t.Errorf("same boot: %+v", got)
}
if got := LoadCursor(path, "b"); got.EID != 0 || got.Boot != "b" {
t.Errorf("another boot starts from the queue's start: %+v", got)
}
}
// fakeFollow is a zpool events -f whose output is given, and which records how it was ended.
type fakeFollow struct {
out io.Reader
killed bool
waited bool
order []string
}
func (p *fakeFollow) Stdout() io.Reader { return p.out }
func (p *fakeFollow) Kill() error {
p.killed = true
p.order = append(p.order, "kill")
return nil
}
func (p *fakeFollow) Wait() error {
p.waited = true
p.order = append(p.order, "wait")
return errors.New("signal: killed")
}
func TestAFollowThatFailsIsKilledBeforeItIsWaitedFor(t *testing.T) {
long := "Oct 8 2026 21:01:55.889900112 " + strings.Repeat("x", 2<<20) + "\n"
p := &fakeFollow{out: strings.NewReader(long)}
err := FollowOnce(p, io.Discard, &Cursor{})
if err == nil || strings.Join(p.order, ",") != "kill,wait" {
t.Errorf("err %v, order %v", err, p.order)
}
}
func TestTheFollowerGivesUpAfterRepeatedFailuresSoItsUnitShowsIt(t *testing.T) {
starts := 0
start := func(context.Context) (FollowProcess, error) {
starts++
return nil, fmt.Errorf("zpool: no such file")
}
var out bytes.Buffer
err := RunFollower(context.Background(), &out, start, &Cursor{}, "", func(time.Duration) {})
if err == nil || starts != MaxFollowFailures {
t.Errorf("err %v after %d starts", err, starts)
}
if !strings.Contains(out.String(), "zpool: no such file") {
t.Errorf("each failure is said: %q", out.String())
}
}
// Past error counts are a warning to clear by hand; a count that rises is a fault now.
func TestPastErrorsWarnAndNewErrorsAreUnhealthy(t *testing.T) {
f := newFake(t)
f.on(line("zpool", PoolsArgv()), "slow\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on(line("zpool", StatusArgv()), fixtureSection(t, "zpool-status-rebuild.txt", "slow"))
m := f.machine()
h := m.Health(context.Background())
if !h.Healthy || !strings.Contains(strings.Join(h.Warnings, " "), "sda in pool slow has 2 past checksum errors; clear them by hand once understood") {
t.Fatalf("first look: %+v", h)
}
f.now = f.now.Add(time.Minute)
f.on(line("zpool", StatusArgv()), strings.Replace(fixtureSection(t, "zpool-status-rebuild.txt", "slow"), "0 0 2", "0 0 5", 1))
h = m.Health(context.Background())
if h.Healthy || !strings.Contains(h.Why, "sda in pool slow: checksum errors rose from 2 to 5") {
t.Fatalf("a rise: %+v", h)
}
f.now = f.now.Add(time.Minute)
if h := m.Health(context.Background()); h.Healthy {
t.Errorf("a rise stays unhealthy for %s, so two looks see it: %+v", ErrorsFresh, h)
}
f.now = f.now.Add(ErrorsFresh)
if h := m.Health(context.Background()); !h.Healthy || !strings.Contains(strings.Join(h.Warnings, " "), "5 past checksum errors") {
t.Errorf("after %s without a rise, a warning: %+v", ErrorsFresh, h)
}
}
func TestAResilverRunningWarnsAndOneFinishedWithErrorsIsUnhealthy(t *testing.T) {
f := newFake(t)
f.on(line("zpool", PoolsArgv()), "fast\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on(line("zpool", StatusArgv()), fixtureSection(t, "zpool-status-rebuild.txt", "fast"))
h := f.machine().Health(context.Background())
if !h.Healthy || !strings.Contains(strings.Join(h.Warnings, " "), "A resilver of mirror-1 in pool fast is running: 16.84 % done, 02:40:11 to go") {
t.Errorf("running: %+v", h)
}
f.on(line("zpool", StatusArgv()), strings.Replace(fixtureSection(t, "zpool-status-rebuild.txt", "fast"),
"1.20T in 03:22:11 with 0 errors", "1.20T in 03:22:11 with 4 errors", 1))
if h := newMachineLike(f).Health(context.Background()); h.Healthy || !strings.Contains(h.Why, "The resilver of mirror-0 in pool fast finished with 4 errors") {
t.Errorf("finished with errors: %+v", h)
}
}
func newMachineLike(f *fake) *Machine { return f.machine() }
// fixtureSection is one pool's part of a status fixture.
func fixtureSection(t *testing.T, name, pool string) string {
t.Helper()
all := fixture(t, name)
start := strings.Index(all, " pool: "+pool+"\n")
if start < 0 {
t.Fatalf("no pool %s in %s", pool, name)
}
rest := all[start+1:]
if end := strings.Index(rest, "\n pool: "); end >= 0 {
return all[start : start+1+end+1]
}
return all[start:]
}
// The scan line's sizes are human even under -p (zfs_nicebytes in print_scan_scrub_resilver_status), and
// status prints a rebuild's resilver per vdev and an error scrub under `scrub:`.
func TestEveryScanLineOfTheSourceIsRead(t *testing.T) {
st, err := ParseStatus(fixture(t, "zpool-status-rebuild.txt"))
if err != nil {
t.Fatal(err)
}
fast, slow, paused := st[0], st[1], st[2]
if len(fast.Scans) != 3 {
t.Fatalf("fast: an error scrub and two rebuilds: %+v", fast.Scans)
}
es := fast.Scans[0]
if es.Kind != "error scrub" || es.State != "finished" || es.Took != "0 days 00:01:02" || es.Ended == nil {
t.Errorf("error scrub: %+v", es)
}
done, running := fast.Scans[1], fast.Scans[2]
if done.Kind != "resilver" || done.Vdev != "mirror-0" || done.State != "finished" || done.Repaired != "1.20T" || deref(done.Errors) != 0 {
t.Errorf("finished rebuild: %+v", done)
}
if running.Vdev != "mirror-1" || running.State != "running" || running.Done == nil || *running.Done != 16.84 || running.ToGo != "02:40:11" {
t.Errorf("running rebuild: %+v", running)
}
if fast.Scrub() != nil {
t.Errorf("fast has no scrub on record: %+v", fast.Scrub())
}
ep := slow.Scans[0]
if ep.Kind != "error scrub" || ep.State != "paused" || ep.Started == nil || ep.Paused == nil || *ep.Done != 42 {
t.Errorf("paused error scrub: %+v", ep)
}
sp := paused.Scrub()
if sp == nil || sp.State != "paused" || !sp.Started.Equal(time.Date(2026, 10, 8, 1, 0, 0, 0, time.Local)) ||
!sp.Paused.Equal(time.Date(2026, 10, 8, 12, 0, 0, 0, time.Local)) || *sp.Done != 43.95 || sp.ToGo != "" {
t.Errorf("paused scrub: %+v", sp)
}
st, _ = ParseStatus(fixture(t, "zpool-status.txt"))
if s := st[1].Scrub(); s == nil || s.Repaired != "0B" || s.State != "finished" {
t.Errorf("a finished scrub with a human size: %+v", s)
}
st, _ = ParseStatus(fixture(t, "zpool-status-scrubbing.txt"))
if s := st[0].Scrub(); s == nil || s.Repaired != "0B" || *s.Done != 29.17 {
t.Errorf("a running scrub: %+v", s)
}
if s := parseScan("scan", "scrub in progress since Sun Oct 4 00:24:01 2026\n12.3T / 26.2T scanned, 0B / 26.2T issued\n0B repaired, 0.00% done, no estimated completion time"); s.ToGo != "" || !s.NoEstimate {
t.Errorf("no estimate: %+v", s)
}
}
// zpool iostat -H prints no section lines (a blank line stands before each class), and under -p a vdev
// without capacity stats prints 0 for both.
func TestIOWithoutSectionLines(t *testing.T) {
st, _ := ParseStatus(fixture(t, "zpool-status.txt"))
rows, err := ParseIO(fixture(t, "zpool-iostat.txt"), st)
if err != nil {
t.Fatal(err)
}
for _, r := range rows {
if r.Kind == "section" {
t.Errorf("a section line: %+v", r)
}
}
if len(rows) != 10 || rows[2].Allocated != nil || rows[2].Free != nil || rows[4].Kind != "vdev" || rows[4].Role != "log" ||
rows[7].Role != "cache" {
t.Errorf("rows: %+v", rows)
}
}
// The error counts outlive a restart of the bundle: what was seen before is kept in a small file, so a
// count that rose while the bundle was down is told from one that was there before.
func TestTheErrorMemoryIsKeptAcrossARestart(t *testing.T) {
path := filepath.Join(t.TempDir(), "errors.json")
f := newFake(t)
f.errors = LoadErrorMemory(path)
f.on(line("zpool", PoolsArgv()), "slow\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on(line("zpool", StatusArgv()), fixtureSection(t, "zpool-status-rebuild.txt", "slow"))
if h := f.machine().Health(context.Background()); !h.Healthy {
t.Fatalf("first look: %+v", h)
}
// Restarted: a new memory from the file, and the count rose meanwhile.
f.errors = LoadErrorMemory(path)
f.now = f.now.Add(time.Hour)
f.on(line("zpool", StatusArgv()), strings.Replace(fixtureSection(t, "zpool-status-rebuild.txt", "slow"), "0 0 2", "0 0 9", 1))
if h := f.machine().Health(context.Background()); h.Healthy || !strings.Contains(h.Why, "checksum errors rose from 2 to 9") {
t.Errorf("after a restart: %+v", h)
}
}