mesh/merge-gate pass: builds new: modules/zfs, sent nowhere; no bus step; every machine composes with the change as it did without (4 of 4 compose)
mesh/repo-check fail: its merge-check.sh failed: --- FAIL: TestASnapshotIsAnsweredFromTheListAfterwardsAndRecorded (0.00s)
mesh/delivery superseded: a newer head of the same pull request
The home server's pool holds the operator's media library, and no mesh tool could say whether it is healthy, how full it is, when it was last scrubbed or what snapshots exist; the only way was a login. This module reads all of it with fixed zfs and zpool commands (list, get, status, iostat, events, history) and has one act, zfs_snapshot, which only adds a snapshot: refused for an unknown dataset, a name outside [A-Za-z0-9._-], an existing snapshot or no why; run as sudo -n zfs snapshot -- ds@name; answered from zfs list afterwards, with any other snapshot that came or went meanwhile; recorded as the event snapshot.taken. No destroy, rollback, set or send exists, and a test holds every command to its verb. Health (ADR 0240): zfs_health on the watch process, unhealthy for a pool not ONLINE, device or data errors, a pool past 90 % full or no ZFS at all; past 80 % and a scrub older than 35 days are warnings. Beside it, the event follower (zpool events -f, as root) keeps ZFS's errors in its journal past a reboot and is judged by its unit, the check the module does not run itself. No seat (ADR 0121), no package: ZFS is the machine's.
209 lines
8.0 KiB
Go
209 lines
8.0 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", false)...)
|
|
if got := p + " " + strings.Join(argv, " "); got != takeLine {
|
|
t.Errorf("ran %q", got)
|
|
}
|
|
p, argv = m.Escalated("zfs", SnapshotArgv("tank", "x", true)...)
|
|
if got := p + " " + strings.Join(argv, " "); got != "sudo -n zfs snapshot -r -- tank@x" {
|
|
t.Errorf("recursive: %q", got)
|
|
}
|
|
root := &Machine{UID: 0}
|
|
if p, argv := root.Escalated("zfs", SnapshotArgv("tank", "x", false)...); 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\t1791547200\t0\t26938034880512")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "before moving the library", false)
|
|
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(1791547200, 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", false)
|
|
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", false)
|
|
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\t1791547200\t0\t1")
|
|
f.emitErr = errors.New("the bus did not accept it")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false)
|
|
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\t1791547200\t0\t1", "tank/downloads@auto-1200\t1791547201\t0\t1")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank/media", "before-move", "why", false)
|
|
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, false)
|
|
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 TestRecursiveIsRefusedWhenAChildHasTheNameAlready(t *testing.T) {
|
|
f := withZFS(t)
|
|
_, err := f.machine().TakeSnapshot(context.Background(), "tank", "weekly", "why", true)
|
|
if err == nil || !strings.Contains(err.Error(), "tank/downloads@weekly already exists") {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
// Without recursive, only tank's own name counts.
|
|
f = withZFS(t)
|
|
snapshotting(f, "sudo -n zfs snapshot -- tank@weekly", "tank@weekly\t1791547200\t0\t1")
|
|
if a, err := f.machine().TakeSnapshot(context.Background(), "tank", "weekly", "why", false); err != nil || len(a.Made) != 1 {
|
|
t.Errorf("tank@weekly alone: %v %+v", err, a)
|
|
}
|
|
}
|
|
|
|
func TestRecursiveAnswersEveryChildsSnapshot(t *testing.T) {
|
|
f := withZFS(t)
|
|
snapshotting(f, "sudo -n zfs snapshot -r -- tank@all", "tank@all\t1\t0\t1", "tank/media@all\t1\t0\t1",
|
|
"tank/downloads@all\t1\t0\t1", "tank/vm@all\t1\t0\t1")
|
|
a, err := f.machine().TakeSnapshot(context.Background(), "tank", "all", "why", true)
|
|
if err != nil || len(a.Made) != 4 || !a.Recursive {
|
|
t.Fatalf("%v %+v", err, a)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|