The safety review of the first version found it safe for the pool's data and asked for these fixes before it goes on the home server: - zfs_snapshot: no recursive. Every answer gives the dataset's free space and the pool's capacity, and says that the snapshot holds every block deleted or overwritten until it is destroyed by hand. - A command that runs out of time is ended with its whole process group. An act that timed out is answered from the list, or as "outcome unknown", never as "NOT DONE". - The watch runs as the operator account. - The event follower reads ZFS's queue from its start, with a cursor per boot, so the pool's import and any downtime reach the journal once. It writes "dropped N events", kills zpool before waiting for it, and exits non-zero after five failed follows in a row. - Past error counts are a warning, and counts that rise are unhealthy. - The guard test catches .Run(, exec.Command(, exec.CommandContext( and process starts. - The fixtures and the parser follow OpenZFS 2.4.4's own printers: human scan sizes under -p, no iostat section lines, error scrubs, per-vdev rebuild resilvers, paused scrubs. The operator also asked the module to own the scrub. A timer and a service of the module's own run zpool scrub -w -- <pool> from the scrub-pool, scrub-cadence, scrub-day and scrub-time settings. In the same declaration, the distribution's weekly and monthly timers for that pool are held stopped and disabled, so exactly one schedule scrubs it. zfs_scrub shows the schedule and the next run, with any resilver's progress. zfs_scrub_start, zfs_scrub_pause and zfs_scrub_stop act with why. The health check warns of a late or failed scrub and of a running resilver, and is unhealthy for a resilver that finished with errors.
181 lines
6.7 KiB
Go
181 lines
6.7 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
const takeLine = "sudo -n zfs snapshot -- tank/media@before-move"
|
|
|
|
// snapshotting makes the fake's snapshot command add a snapshot to what the list then answers, with a
|
|
// creation time of its own: the answer must carry the list's time, not the clock's.
|
|
func snapshotting(f *fake, cmd string, added ...string) {
|
|
f.on(cmd, "")
|
|
f.after[cmd] = func(f *fake) {
|
|
key := line("zfs", SnapshotsArgv())
|
|
r := f.answers[key]
|
|
for _, a := range added {
|
|
r.Out += a + "\n"
|
|
}
|
|
f.answers[key] = r
|
|
}
|
|
}
|
|
|
|
func TestTheSnapshotCommandIsFixedWordsThroughSudoWithTheNameAfterDashDash(t *testing.T) {
|
|
m := &Machine{UID: 1000}
|
|
p, argv := m.Escalated("zfs", SnapshotArgv("tank/media", "before-move")...)
|
|
if got := p + " " + strings.Join(argv, " "); got != takeLine {
|
|
t.Errorf("ran %q", got)
|
|
}
|
|
root := &Machine{UID: 0}
|
|
if p, argv := root.Escalated("zfs", SnapshotArgv("tank", "x")...); p != "zfs" || argv[0] != "snapshot" {
|
|
t.Errorf("as root, no sudo: %s %v", p, argv)
|
|
}
|
|
}
|
|
|
|
func TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded(t *testing.T) {
|
|
f := withZFS(t)
|
|
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t26938034880512")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "before moving the library")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !a.Done || len(a.Made) != 1 || a.Made[0].Name != "tank/media@before-move" || a.Made[0].Referenced != 26938034880512 {
|
|
t.Fatalf("answer: %+v", a)
|
|
}
|
|
if a.Made[0].Created != time.Unix(1791400000, 0).Local().Format(time.RFC3339) || a.Made[0].Created == f.now.Format(time.RFC3339) {
|
|
t.Errorf("the creation time is the list's: %s", a.Made[0].Created)
|
|
}
|
|
if len(a.Others) != 0 || !strings.Contains(a.Said, "No other snapshot came or went") || !strings.Contains(a.Said, "Why: before moving the library") {
|
|
t.Errorf("said: %s / %v", a.Said, a.Others)
|
|
}
|
|
if strings.Join(a.Ran, " ") != takeLine || len(ran(f, "sudo")) != 1 {
|
|
t.Errorf("ran: %v; every call: %v", a.Ran, f.calls)
|
|
}
|
|
if len(f.emitted) != 1 || f.emitted[0]["_type"] != SnapshotEvent || f.emitted[0]["why"] != "before moving the library" ||
|
|
f.emitted[0]["done"] != true || f.emitted[0]["made"] != "tank/media@before-move" || !a.Recorded {
|
|
t.Errorf("recorded: %+v", f.emitted)
|
|
}
|
|
}
|
|
|
|
func TestASnapshotTheListDoesNotShowIsNotAnsweredAsMade(t *testing.T) {
|
|
f := withZFS(t)
|
|
f.on(takeLine, "") // zfs exits 0, and the list does not change
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
|
|
if err == nil || a.Done || !strings.Contains(err.Error(), "the list shows 0 of the 1") {
|
|
t.Fatalf("err %v, answer %+v", err, a)
|
|
}
|
|
if len(f.emitted) != 1 || f.emitted[0]["done"] != false {
|
|
t.Errorf("the failed act is recorded too: %+v", f.emitted)
|
|
}
|
|
}
|
|
|
|
func TestARefusedSudoIsSaidAndRecorded(t *testing.T) {
|
|
f := withZFS(t)
|
|
f.answers[takeLine] = Ran{Code: 1, Err: "sudo: a password is required"}
|
|
_, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
|
|
if err == nil || !strings.Contains(err.Error(), "NOT DONE") || !strings.Contains(err.Error(), "sudo refused the operator account") {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if len(f.emitted) != 1 || f.emitted[0]["done"] != false {
|
|
t.Errorf("recorded: %+v", f.emitted)
|
|
}
|
|
}
|
|
|
|
func TestAnUnrecordedActSaysSoLoudly(t *testing.T) {
|
|
f := withZFS(t)
|
|
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1")
|
|
f.emitErr = errors.New("the bus did not accept it")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a.Recorded || !strings.Contains(a.Said, "NOT RECORDED on the bus: the bus did not accept it") {
|
|
t.Errorf("said: %s", a.Said)
|
|
}
|
|
}
|
|
|
|
func TestAnotherSnapshotMeanwhileIsSaidNotHidden(t *testing.T) {
|
|
f := withZFS(t)
|
|
snapshotting(f, takeLine, "tank/media@before-move\t1791400000\t0\t1", "tank/downloads@auto-1200\t1791547201\t0\t1")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(a.Others) != 1 || a.Others[0] != "appeared: tank/downloads@auto-1200" || !strings.Contains(a.Said, "not by this act") {
|
|
t.Errorf("others: %v; said %s", a.Others, a.Said)
|
|
}
|
|
}
|
|
|
|
func TestEveryRefusalRunsNoSnapshotCommand(t *testing.T) {
|
|
cases := []struct{ dataset, name, why, says string }{
|
|
{"tank/nope", "x", "why", "no dataset"},
|
|
{"", "x", "why", "name the dataset"},
|
|
{"tank/media@x", "y", "why", "is not a dataset"},
|
|
{"tank/media", "", "why", "name the snapshot"},
|
|
{"tank/media", "a b", "why", "may hold only"},
|
|
{"tank/media", "x;rm -rf /", "why", "may hold only"},
|
|
{"tank/media", "$(reboot)", "why", "may hold only"},
|
|
{"tank/media", "a/b", "why", "may hold only"},
|
|
{"tank/media", "a@b", "why", "may hold only"},
|
|
{"tank/media", "-r", "why", ""}, // a name may start with '-': it follows -- and the dataset's @
|
|
{"tank/media", strings.Repeat("x", 250), "why", "longer than"},
|
|
{"tank/media", "before-rename", "why", "already exists"},
|
|
{"tank/media", "x", "", "say why"},
|
|
{"tank/media", "x", "one\ntwo", "one line"},
|
|
}
|
|
for _, c := range cases {
|
|
f := withZFS(t)
|
|
_, err := f.machine().TakeSnapshot(context.Background(), c.dataset, c.name, c.why)
|
|
if c.says == "" {
|
|
if len(ran(f, "sudo -n zfs snapshot -- tank/media@-r")) != 1 {
|
|
t.Errorf("%q: the name follows -- and runs as a name: %v", c.name, f.calls)
|
|
}
|
|
continue
|
|
}
|
|
if err == nil || !strings.Contains(err.Error(), c.says) {
|
|
t.Errorf("%q@%q why %q: %v", c.dataset, c.name, c.why, err)
|
|
}
|
|
if n := len(ran(f, "sudo")); n != 0 {
|
|
t.Errorf("%q@%q: a refusal ran %v", c.dataset, c.name, f.calls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAMachineWithoutZFSSaysTheModuleDoesNotApply(t *testing.T) {
|
|
f := newFake(t) // every command: executable file not found
|
|
m := f.machine()
|
|
for _, tool := range Tools(m) {
|
|
if tool.Name == "zfs_health" {
|
|
continue
|
|
}
|
|
args := map[string]any{"dataset": "tank", "name": "x", "why": "y"}
|
|
got, err := tool.Run(args)
|
|
if err != nil {
|
|
t.Errorf("%s: %v", tool.Name, err)
|
|
continue
|
|
}
|
|
na, ok := got.(NotApplicable)
|
|
if !ok || na.Applicable || !strings.Contains(na.Said, "does not apply here") {
|
|
t.Errorf("%s: %+v", tool.Name, got)
|
|
}
|
|
}
|
|
h := m.Health(context.Background())
|
|
if h.Healthy || !strings.Contains(h.Why, "not installed") || !strings.Contains(h.Why, "Unassign") {
|
|
t.Errorf("health: %+v", h)
|
|
}
|
|
if n := len(ran(f, "sudo -n zfs snapshot")); n != 0 {
|
|
t.Errorf("no snapshot is tried: %v", f.calls)
|
|
}
|
|
}
|
|
|
|
func TestAMissingKernelModuleIsNotApplicableToo(t *testing.T) {
|
|
r := Ran{Code: 1, Err: "The ZFS modules cannot be auto-loaded.\nTry running 'modprobe zfs' as root to manually load them."}
|
|
if why := absent(r); !strings.Contains(why, "kernel module is not loaded") {
|
|
t.Errorf("absent: %q", why)
|
|
}
|
|
}
|