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

111 lines
3.1 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The fixtures in testdata are the output of OpenZFS 2.4 (the version the home server runs) in the exact
// form each command prints it — tab-separated under -H, exact numbers under -p — with the pool, disk and
// address names replaced by documentation ones.
func fixture(t *testing.T, name string) string {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatal(err)
}
return string(b)
}
// fake is a machine for a test: a table of command lines and their answers, every call kept.
type fake struct {
t *testing.T
answers map[string]Ran
// after runs once a command line was answered: a snapshot's command changes what the list answers.
after map[string]func(*fake)
calls []string
emitted []map[string]any
emitErr error
files map[string]string
now time.Time
uid int
errors *ErrorMemory
}
func newFake(t *testing.T) *fake {
return &fake{t: t, answers: map[string]Ran{}, after: map[string]func(*fake){}, files: map[string]string{},
now: time.Date(2026, 10, 9, 12, 0, 0, 0, time.Local), uid: 1000, errors: NewErrorMemory()}
}
func (f *fake) on(line string, out string) { f.answers[line] = Ran{Out: out} }
func (f *fake) machine() *Machine {
return &Machine{
Run: func(_ context.Context, name string, args ...string) Ran {
line := strings.TrimSpace(name + " " + strings.Join(args, " "))
f.calls = append(f.calls, line)
r, ok := f.answers[line]
if !ok && name == "sudo" && len(args) > 1 {
// sudo is there; the command it was asked for is not.
r = Ran{Code: 1, Err: "sudo: " + args[1] + ": command not found"}
} else if !ok {
r = Ran{Code: -1, Err: "exec: \"" + name + "\": executable file not found in $PATH"}
}
if then, ok := f.after[line]; ok {
then(f)
}
return r
},
UID: f.uid,
Now: func() time.Time { return f.now },
ReadFile: func(p string) ([]byte, error) {
s, ok := f.files[p]
if !ok {
if strings.HasPrefix(p, os.TempDir()) {
// What the code under test wrote into a test's own directory.
return os.ReadFile(p)
}
return nil, os.ErrNotExist
}
return []byte(s), nil
},
Errors: f.errors,
Emit: func(eventType string, body any) error {
if f.emitErr != nil {
return f.emitErr
}
b := body.(map[string]any)
b["_type"] = eventType
f.emitted = append(f.emitted, b)
return nil
},
}
}
func line(name string, argv []string) string { return name + " " + strings.Join(argv, " ") }
// withZFS answers the read commands from the healthy fixtures.
func withZFS(t *testing.T) *fake {
f := newFake(t)
f.on(line("zfs", DatasetsArgv()), fixture(t, "zfs-list.txt"))
f.on(line("zfs", SnapshotsArgv()), fixture(t, "zfs-list-snapshots.txt"))
f.on(line("zpool", PoolsArgv()), fixture(t, "zpool-list.txt"))
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status.txt"))
return f
}
func ran(f *fake, prefix string) []string {
var out []string
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
out = append(out, c)
}
}
return out
}