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.
207 lines
8.0 KiB
Go
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")
|
|
}
|
|
}
|