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

120 lines
4.6 KiB
Go

package main
import (
"context"
"strings"
"testing"
"time"
)
const scrubConf = "pool=tank\ncadence=weekly\nday=Mon\ntime=00:00\n"
// scheduled is the healthy fixtures with the module's scrub schedule for tank, its timer waiting and its
// last run a success.
func scheduled(t *testing.T) *fake {
f := withZFS(t)
f.files[ScrubConfig] = scrubConf
f.on(line("systemctl", TimerShowArgv(ScrubTimer)),
"ActiveState=active\nUnitFileState=enabled\nNextElapseUSecRealtime=@1791842400\nLastTriggerUSec=@1791237600\n")
f.on(line("systemctl", ServiceShowArgv(ScrubService)),
"ActiveState=inactive\nResult=success\nExecMainStartTimestamp=@1791237600\nExecMainExitTimestamp=@1791331200\n")
return f
}
func TestTheScheduleIsTheConfigAndWhatTheTimerSays(t *testing.T) {
f := scheduled(t)
got, err := f.machine().Scrubs(context.Background(), false)
if err != nil {
t.Fatal(err)
}
a := got.(ScrubAnswer)
s := a.Schedule
if s == nil || s.Pool != "tank" || s.Cadence != "weekly" || s.Timer != "zfs-mesh-scrub.timer" || s.TimerState != "active" ||
s.Enabled != "enabled" || s.Next == nil || !s.Next.Equal(time.Date(2026, 10, 12, 0, 0, 0, 0, time.Local)) || s.LastResult != "success" {
t.Fatalf("schedule: %+v", s)
}
if want := s.NextSlot(f.now).Format("2006-01-02 15:04"); !strings.Contains(strings.Join(a.Said, "\n"), "next scheduled scrub of tank: "+want) || want != "2026-10-12 00:00" {
t.Errorf("said: %v", a.Said)
}
}
func TestAScheduleThatIsNotSetIsSaidAndAnInvalidOneRefused(t *testing.T) {
f := withZFS(t)
got, _ := f.machine().Scrubs(context.Background(), false)
if a := got.(ScrubAnswer); a.Schedule != nil || !strings.Contains(strings.Join(a.Said, " "), "no scrub schedule") {
t.Errorf("no config: %+v", a)
}
for _, conf := range []string{
"pool=tank\ncadence=daily\nday=Mon\ntime=00:00\n",
"pool=tank\ncadence=weekly\nday=Monday\ntime=00:00\n",
"pool=tank\ncadence=weekly\nday=Mon\ntime=25:00\n",
"pool=tank x\ncadence=weekly\nday=Mon\ntime=00:00\n",
} {
if _, err := ParseScrubConfig(conf); err == nil {
t.Errorf("accepted %q", conf)
}
}
}
func TestTheScheduleWarnsOfAnOldScrubAFailedRunAndAStoppedTimer(t *testing.T) {
f := scheduled(t)
h := f.machine().Health(context.Background())
if !h.Healthy {
t.Fatalf("%+v", h)
}
w := strings.Join(h.Warnings, " ")
// tank's last scrub finished 2026-09-14, 25 days before the fake clock: past weekly plus its margin.
if !strings.Contains(w, "Pool tank was last scrubbed 25 days ago, more than its weekly schedule allows (9 days)") {
t.Errorf("warnings: %v", h.Warnings)
}
f.on(line("systemctl", ServiceShowArgv(ScrubService)),
"ActiveState=failed\nResult=exit-code\nExecMainStartTimestamp=@1791237600\nExecMainExitTimestamp=@1791237601\n")
f.on(line("systemctl", TimerShowArgv(ScrubTimer)),
"ActiveState=inactive\nUnitFileState=disabled\nNextElapseUSecRealtime=\nLastTriggerUSec=@1791237600\n")
w = strings.Join(f.machine().Health(context.Background()).Warnings, " ")
for _, want := range []string{"The last scheduled scrub of tank failed (exit-code)", "The scrub schedule's timer zfs-mesh-scrub.timer is inactive and disabled"} {
if !strings.Contains(w, want) {
t.Errorf("lacks %q: %s", want, w)
}
}
}
func TestTheScrubActsAreFixedCommandsWithWhyReadBackAndRecorded(t *testing.T) {
for act, cmd := range map[string]string{
"start": "sudo -n zpool scrub -- tank",
"pause": "sudo -n zpool scrub -p -- tank",
"stop": "sudo -n zpool scrub -s -- tank",
} {
f := withZFS(t)
f.on(cmd, "")
a, err := f.machine().ScrubAct(context.Background(), act, "tank", "the operator asked")
if err != nil {
t.Fatalf("%s: %v", act, err)
}
if strings.Join(a.Ran, " ") != cmd || a.After == "" || !a.Done {
t.Errorf("%s: %+v", act, a)
}
if len(f.emitted) != 1 || f.emitted[0]["_type"] != ScrubEvent || f.emitted[0]["act"] != act || f.emitted[0]["why"] != "the operator asked" {
t.Errorf("%s recorded: %+v", act, f.emitted)
}
}
f := withZFS(t)
for _, c := range []struct{ act, pool, why, says string }{
{"start", "nope", "why", "no pool"},
{"start", "tank", "", "say why"},
{"start", "-s", "why", "no pool"},
{"destroy", "tank", "why", "not an act"},
} {
if _, err := f.machine().ScrubAct(context.Background(), c.act, c.pool, c.why); err == nil || !strings.Contains(err.Error(), c.says) {
t.Errorf("%+v: %v", c, err)
}
}
if n := len(ran(f, "sudo")); n != 0 {
t.Errorf("a refusal ran %v", f.calls)
}
f.answers["sudo -n zpool scrub -- tank"] = Ran{Code: -1, TimedOut: true}
if _, err := f.machine().ScrubAct(context.Background(), "start", "tank", "why"); err == nil || !strings.Contains(err.Error(), "outcome unknown; check zfs_scrub") {
t.Errorf("timed out: %v", err)
}
}