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") } }