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.
111 lines
3.1 KiB
Go
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
|
|
}
|