Files
mesh-catalog/modules/zfs/cmd/zfs-tools/scrubrun_test.go
T
jochen a9acd3dd28
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 delivered
Keep the scheduled run's served slot in a folder of root's alone, written never through a link
Third review of #147. The run writes as root into /var/lib/zfs-scrub (0700, root's), apart from the
operator account's error counts in /var/lib/zfs-tools, and refuses a folder that is a link, open to
others or someone else's, and a file that is a link: a new file opened O_NOFOLLOW|O_EXCL, renamed over.
2026-10-09 01:55:54 +02:00

207 lines
8.0 KiB
Go

package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// The scheduled run (`zfs-tools scrub-run`, second review of mesh-catalog #147): the node-engine's timer
// fires it every quarter of an hour, and it decides from the configuration whether a scrub is due. It
// starts one, or waits for one already running, as upstream's zfs-scrub@.service does, and never pauses or
// stops a scrub.
func monthlyConf(pool string) string {
return "pool=" + pool + "\ncadence=monthly\nday=Mon\ntime=00:00\n"
}
func TestTheSlotIsTheLastScheduledTimeNotAfterNow(t *testing.T) {
loc := time.Local
at := func(y int, m time.Month, d, h, min int) time.Time { return time.Date(y, m, d, h, min, 0, 0, loc) }
cases := []struct {
conf string
now time.Time
want time.Time
}{
{"pool=tank\ncadence=weekly\nday=Mon\ntime=00:00\n", at(2026, 10, 9, 12, 0), at(2026, 10, 5, 0, 0)},
{"pool=tank\ncadence=weekly\nday=Fri\ntime=13:30\n", at(2026, 10, 9, 12, 0), at(2026, 10, 2, 13, 30)},
{"pool=tank\ncadence=weekly\nday=Fri\ntime=11:30\n", at(2026, 10, 9, 12, 0), at(2026, 10, 9, 11, 30)},
{monthlyConf("tank"), at(2026, 10, 9, 12, 0), at(2026, 10, 5, 0, 0)},
{monthlyConf("tank"), at(2026, 10, 4, 23, 0), at(2026, 9, 7, 0, 0)},
{"pool=tank\ncadence=monthly\nday=Thu\ntime=06:00\n", at(2026, 10, 1, 6, 0), at(2026, 10, 1, 6, 0)},
}
for _, c := range cases {
s, err := ParseScrubConfig(c.conf)
if err != nil {
t.Fatal(err)
}
if got := s.Slot(c.now); !got.Equal(c.want) {
t.Errorf("%q at %s: slot %s, want %s", c.conf, c.now, got, c.want)
}
if next := s.NextSlot(c.now); !next.After(c.now) || !s.Slot(next).Equal(next) {
t.Errorf("%q at %s: next slot %s", c.conf, c.now, next)
}
}
}
// root runs the scheduled run: no sudo.
func rootFake(t *testing.T, conf string) (*fake, string) {
f := withZFS(t)
f.uid = 0
f.files[ScrubConfig] = conf
dir := filepath.Join(t.TempDir(), "zfs-scrub")
return f, filepath.Join(dir, "scrub-served.json")
}
func TestADueScrubIsStartedThenWaitedForAndMarkedServed(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.on("zpool scrub -- tank", "")
f.on("zpool wait -t scrub -- tank", "")
said, err := f.machine().ScrubRun(context.Background(), served)
if err != nil {
t.Fatal(err)
}
if got := strings.Join(ran(f, "zpool scrub"), "|") + "/" + strings.Join(ran(f, "zpool wait"), "|"); got != "zpool scrub -- tank/zpool wait -t scrub -- tank" {
t.Errorf("ran %q", got)
}
if !strings.Contains(said, "started") {
t.Errorf("said %q", said)
}
// Served: the next fire within the same slot runs nothing.
f.calls = nil
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
if n := len(ran(f, "zpool scrub")) + len(ran(f, "zpool wait")); n != 0 {
t.Errorf("a served slot ran %v", f.calls)
}
}
func TestAScrubInProgressIsWaitedForNeverPausedOrStarted(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-scrubbing.txt"))
f.on(line("zpool", PoolsArgv()), "tank\t1000\t100\t900\t10\t1\tONLINE\t1.00\n")
f.on("zpool wait -t scrub -- tank", "")
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
if strings.Join(f.calls, "|") == "" || len(ran(f, "zpool scrub")) != 0 || len(ran(f, "zpool wait -t scrub -- tank")) != 1 {
t.Errorf("ran %v", f.calls)
}
}
func TestAStartRefusedBecauseAScrubJustBeganIsWaitedForNotPaused(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.answers["zpool scrub -- tank"] = Ran{Code: 1, Err: "cannot scrub tank: currently scrubbing; use 'zpool scrub -s' to cancel the current scrub"}
f.on("zpool wait -t scrub -- tank", "")
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
for _, c := range f.calls {
if strings.Contains(c, "scrub -p") || strings.Contains(c, "scrub -s") {
t.Errorf("the run paused or stopped a scrub: %s", c)
}
}
if len(ran(f, "zpool wait -t scrub -- tank")) != 1 {
t.Errorf("not waited for: %v", f.calls)
}
}
func TestAResilverDefersTheScrubWithoutServingTheSlot(t *testing.T) {
f, served := rootFake(t, monthlyConf("tank"))
f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt"))
f.on(line("zpool", PoolsArgv()), "tank\t1000\t100\t900\t10\t1\tDEGRADED\t1.00\n")
said, err := f.machine().ScrubRun(context.Background(), served)
if err != nil {
t.Fatal(err)
}
if len(ran(f, "zpool scrub")) != 0 || !strings.Contains(said, "resilver") {
t.Errorf("ran %v, said %q", f.calls, said)
}
if _, err := os.Stat(served); err == nil {
t.Errorf("the slot was marked served while the scrub waits for the resilver")
}
}
func TestAScrubAlreadyStartedInTheSlotServesItWithoutAStateFile(t *testing.T) {
// tank's scrub started 2026-09-13 00:24 (finished 09-14); a weekly Sunday-00:00 slot of 09-13 is served
// by it, so a run on 09-15 starts nothing.
f, served := rootFake(t, "pool=tank\ncadence=weekly\nday=Sun\ntime=00:00\n")
f.now = time.Date(2026, 9, 15, 12, 0, 0, 0, time.Local)
if _, err := f.machine().ScrubRun(context.Background(), served); err != nil {
t.Fatal(err)
}
if n := len(ran(f, "zpool scrub")) + len(ran(f, "zpool wait")); n != 0 {
t.Errorf("ran %v", f.calls)
}
}
func TestAnInvalidScheduleRunsNothingAndFails(t *testing.T) {
for _, conf := range []string{
"pool=tank\ncadence=monthly\nday=Mon\ntime=*:*\n",
"pool=tank\ncadence=monthly\nday=Mon..Fri\ntime=00:00\n",
"pool=tank\ncadence=monthly\nday=*\ntime=00:00\n",
"pool=tank\ncadence=hourly\nday=Mon\ntime=00:00\n",
"pool=tank\ncadence=monthly\nday=Mon\ntime=0:00\n",
} {
f, served := rootFake(t, conf)
if _, err := f.machine().ScrubRun(context.Background(), served); err == nil {
t.Errorf("%q: not refused", conf)
}
if len(ran(f, "zpool scrub"))+len(ran(f, "zpool wait")) != 0 {
t.Errorf("%q ran %v", conf, f.calls)
}
}
}
func TestAScheduledScrubThatDidNotStartIsAWarning(t *testing.T) {
f := withZFS(t)
f.files[ScrubConfig] = "pool=tank\ncadence=weekly\nday=Mon\ntime=00:00\n"
f.on(line("systemctl", TimerShowArgv(ScrubTimer)), "ActiveState=active\nUnitFileState=enabled\nNextElapseUSecRealtime=@1791842400\nLastTriggerUSec=@1791237600\n")
h := f.machine().Health(context.Background())
if !strings.Contains(strings.Join(h.Warnings, " "), "The scheduled scrub of tank due 2026-10-05 00:00 has not started") {
t.Errorf("warnings: %v", h.Warnings)
}
}
// Third review: the scheduled run's state is root's alone — a folder of its own, 0700, never through a
// link — so the operator account, or anything it runs, cannot make the run write elsewhere.
func TestTheServedSlotIsWrittenOnlyIntoAPrivateFolderAndNeverThroughALink(t *testing.T) {
dir := filepath.Join(t.TempDir(), "zfs-scrub")
path := filepath.Join(dir, "scrub-served.json")
if err := writeServed(path, map[string]int64{"tank": 1}); err != nil {
t.Fatal(err)
}
if fi, err := os.Lstat(dir); err != nil || fi.Mode().Perm() != 0o700 || !fi.IsDir() {
t.Errorf("the folder: %v %v", fi.Mode(), err)
}
// The file replaced by a link to somewhere else: refused, and the other place untouched.
victim := filepath.Join(t.TempDir(), "victim")
os.WriteFile(victim, []byte("keep"), 0o644)
os.Remove(path)
os.Symlink(victim, path)
if err := writeServed(path, map[string]int64{"tank": 2}); err == nil {
t.Errorf("written through a link")
}
if b, _ := os.ReadFile(victim); string(b) != "keep" {
t.Errorf("the link's target was changed: %q", b)
}
// The folder itself a link, or open to others: refused.
linked := filepath.Join(t.TempDir(), "linked")
os.Symlink(t.TempDir(), linked)
if err := writeServed(filepath.Join(linked, "scrub-served.json"), map[string]int64{"tank": 3}); err == nil {
t.Errorf("written into a linked folder")
}
open := filepath.Join(t.TempDir(), "open")
os.Mkdir(open, 0o755)
if err := writeServed(filepath.Join(open, "scrub-served.json"), map[string]int64{"tank": 3}); err == nil {
t.Errorf("written into a folder others can read")
}
if filepath.Dir(ServedFile) == filepath.Dir(ErrorsFile) {
t.Errorf("the run's state shares the account's folder")
}
}