241 lines
9.9 KiB
Go
241 lines
9.9 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
type devSpec struct {
|
|
state, scan string
|
|
errs bool
|
|
cache [][2]string // held, real
|
|
logs [][][2]string // top-level log vdevs; more than one member is a mirror
|
|
}
|
|
|
|
func (s devSpec) text(real bool) string {
|
|
state, scan := s.state, s.scan
|
|
if state == "" {
|
|
state = "ONLINE"
|
|
}
|
|
if scan == "" {
|
|
scan = "scrub repaired 0B in 00:04:11 with 0 errors on Sun Jul 12 00:28:12 2026"
|
|
}
|
|
cksum := "0"
|
|
if s.errs {
|
|
cksum = "3"
|
|
}
|
|
name := func(p [2]string) string {
|
|
if real {
|
|
return p[1]
|
|
}
|
|
return p[0]
|
|
}
|
|
var b strings.Builder
|
|
fmt.Fprintf(&b, " pool: tank\n state: %s\n scan: %s\nconfig:\n\n\tNAME STATE READ WRITE CKSUM\n\ttank %s 0 0 %s\n", state, scan, state, cksum)
|
|
b.WriteString("\t /dev/disk/by-id/data-1 ONLINE 0 0 0\n")
|
|
if len(s.logs) > 0 {
|
|
b.WriteString("\tlogs\t\n")
|
|
for i, v := range s.logs {
|
|
if len(v) == 1 {
|
|
fmt.Fprintf(&b, "\t %s ONLINE 0 0 0\n", name(v[0]))
|
|
continue
|
|
}
|
|
fmt.Fprintf(&b, "\t mirror-%d ONLINE 0 0 0\n", i)
|
|
for _, m := range v {
|
|
fmt.Fprintf(&b, "\t %s ONLINE 0 0 0\n", name(m))
|
|
}
|
|
}
|
|
}
|
|
if len(s.cache) > 0 {
|
|
b.WriteString("\tcache\t\n")
|
|
for _, c := range s.cache {
|
|
fmt.Fprintf(&b, "\t %s ONLINE 0 0 0\n", name(c))
|
|
}
|
|
}
|
|
b.WriteString("\nerrors: No known data errors\n")
|
|
return b.String()
|
|
}
|
|
|
|
func (s devSpec) set(f *fake) {
|
|
f.on("zpool status -P -- tank", s.text(false))
|
|
f.on("zpool status -P -L -- tank", s.text(true))
|
|
}
|
|
|
|
func dev(n string) [2]string { return [2]string{"/dev/disk/by-id/" + n, "/dev/" + n + "k"} }
|
|
func kern(n string) [2]string { return [2]string{"/dev/" + n + "k", "/dev/" + n + "k"} }
|
|
|
|
// §17, §31 convergence table: a kernel-named entry is re-added by its stable name; an add is after its
|
|
// checks, never -f; an empty list removes nothing; a log removal while not healthy is a wait; a log-mirror
|
|
// change is a removal and a re-add, said so.
|
|
func TestDevicesConvergeFromWhatZFSSays(t *testing.T) {
|
|
const wipeGPT = "DEVICE OFFSET TYPE UUID LABEL\nnvme1k 0x200 gpt\n"
|
|
const wipeZFS = "DEVICE OFFSET TYPE UUID LABEL\nnvme1k 0x4000 zfs_member 123 tank\n"
|
|
cases := []struct {
|
|
name string
|
|
conf string
|
|
before, mid devSpec
|
|
hasMid bool
|
|
after devSpec
|
|
answers map[string]string
|
|
others string
|
|
ran []string
|
|
notRan []string
|
|
event, state string
|
|
wantErr bool
|
|
waitsOrRefuse string
|
|
}{
|
|
{name: "kernel name re-added by its stable name", conf: `{"pool":"tank","cache-devices":["c1"]}`,
|
|
before: devSpec{cache: [][2]string{kern("c1")}}, mid: devSpec{}, hasMid: true, after: devSpec{cache: [][2]string{dev("c1")}},
|
|
answers: map[string]string{"zdb -l /dev/disk/by-id/c1": "name: 'tank'\n", "wipefs --no-act /dev/disk/by-id/c1": wipeZFS},
|
|
ran: []string{"zpool remove -- tank /dev/c1k", "zpool add -- tank cache /dev/disk/by-id/c1"}, event: "config.applied", state: "applied"},
|
|
{name: "declared device not in the pool is added", conf: `{"pool":"tank","cache-devices":["c2"]}`,
|
|
before: devSpec{}, after: devSpec{cache: [][2]string{dev("c2")}},
|
|
answers: map[string]string{"wipefs --no-act /dev/disk/by-id/c2": ""},
|
|
ran: []string{"zpool add -- tank cache /dev/disk/by-id/c2"}, event: "config.applied", state: "applied"},
|
|
{name: "a partition table refuses it", conf: `{"pool":"tank","cache-devices":["c2"]}`,
|
|
before: devSpec{},
|
|
answers: map[string]string{"wipefs --no-act /dev/disk/by-id/c2": wipeGPT},
|
|
notRan: []string{"zpool add"}, event: "config.refused", state: "refused"},
|
|
{name: "another pool's label refuses it", conf: `{"pool":"tank","cache-devices":["c2"]}`,
|
|
before: devSpec{},
|
|
answers: map[string]string{"wipefs --no-act /dev/disk/by-id/c2": wipeZFS, "zdb -l /dev/disk/by-id/c2": "name: 'other'\n"},
|
|
notRan: []string{"zpool add"}, event: "config.refused", state: "refused"},
|
|
{name: "this pool's label allows it", conf: `{"pool":"tank","cache-devices":["c2"]}`,
|
|
before: devSpec{}, after: devSpec{cache: [][2]string{dev("c2")}},
|
|
answers: map[string]string{"wipefs --no-act /dev/disk/by-id/c2": wipeZFS, "zdb -l /dev/disk/by-id/c2": "name: 'tank'\n"},
|
|
ran: []string{"zpool add -- tank cache /dev/disk/by-id/c2"}, event: "config.applied", state: "applied"},
|
|
{name: "a member of another imported pool is refused", conf: `{"pool":"tank","cache-devices":["c2"]}`,
|
|
before: devSpec{}, others: "/dev/c2kp1", notRan: []string{"wipefs", "zpool add"}, event: "config.refused", state: "refused"},
|
|
{name: "a device not present is a wait", conf: `{"pool":"tank","cache-devices":["c9"]}`,
|
|
before: devSpec{}, notRan: []string{"zpool add", "wipefs"}, event: "config.waiting", state: "waiting"},
|
|
{name: "an empty cache list removes nothing", conf: `{"pool":"tank","cache-devices":[],"log-devices":["l1"]}`,
|
|
before: devSpec{cache: [][2]string{dev("old")}, logs: [][][2]string{{dev("l1")}}}, notRan: []string{"zpool remove", "zpool add"}, state: "applied"},
|
|
{name: "a cache entry not named is removed", conf: `{"pool":"tank","cache-devices":["c1"]}`,
|
|
before: devSpec{cache: [][2]string{dev("c1"), dev("old")}}, after: devSpec{cache: [][2]string{dev("c1")}},
|
|
ran: []string{"zpool remove -- tank /dev/disk/by-id/old"}, event: "config.applied", state: "applied"},
|
|
{name: "a log removal while a scrub runs is a wait", conf: `{"pool":"tank","log-devices":["l1"]}`,
|
|
before: devSpec{scan: "scrub in progress since Sun Jul 12 00:28:12 2026", logs: [][][2]string{{kern("l1")}}},
|
|
notRan: []string{"zpool remove", "zpool add"}, event: "config.waiting", state: "waiting"},
|
|
{name: "a log removal with errors is a wait", conf: `{"pool":"tank","log-devices":["l1"]}`,
|
|
before: devSpec{errs: true, logs: [][][2]string{{dev("l1"), dev("x")}}}, notRan: []string{"zpool remove"}, event: "config.waiting", state: "waiting"},
|
|
{name: "a log-mirror change is a removal and a re-add", conf: `{"pool":"tank","log-devices":["l1","l2"],"log-mirror":true}`,
|
|
before: devSpec{logs: [][][2]string{{dev("l1")}, {dev("l2")}}}, mid: devSpec{}, hasMid: true,
|
|
after: devSpec{logs: [][][2]string{{dev("l1"), dev("l2")}}},
|
|
answers: map[string]string{"wipefs --no-act /dev/disk/by-id/l1": "", "wipefs --no-act /dev/disk/by-id/l2": ""},
|
|
ran: []string{"zpool remove -- tank /dev/disk/by-id/l1", "zpool remove -- tank /dev/disk/by-id/l2",
|
|
"zpool add -- tank log mirror /dev/disk/by-id/l1 /dev/disk/by-id/l2"}, event: "config.applied", state: "applied"},
|
|
{name: "everything as set runs nothing", conf: `{"pool":"tank","cache-devices":["c1"],"log-devices":["l1","l2"],"log-mirror":true}`,
|
|
before: devSpec{cache: [][2]string{dev("c1")}, logs: [][][2]string{{dev("l1"), dev("l2")}}},
|
|
notRan: []string{"zpool remove", "zpool add"}, state: "applied"},
|
|
}
|
|
for _, c := range cases {
|
|
f := newFake(t)
|
|
f.uid = 0
|
|
for _, n := range []string{"c1", "c2", "l1", "l2", "old", "x"} {
|
|
f.links["/dev/disk/by-id/"+n] = "/dev/" + n + "k"
|
|
}
|
|
c.before.set(f)
|
|
f.on("zpool status -P -L", " pool: other\n state: ONLINE\nconfig:\n\n\tNAME STATE READ WRITE CKSUM\n\tother ONLINE 0 0 0\n\t "+
|
|
orDefault(c.others, "/dev/zzz")+" ONLINE 0 0 0\n\nerrors: No known data errors\n")
|
|
for k, v := range c.answers {
|
|
f.on(k, v)
|
|
}
|
|
mid := c.mid
|
|
if !c.hasMid {
|
|
mid = c.after
|
|
}
|
|
// A removal moves the pool to mid, an add to after.
|
|
hook := func(spec devSpec) func(*fake) { return func(f *fake) { spec.set(f) } }
|
|
for _, r := range c.ran {
|
|
f.on(r, "")
|
|
if strings.HasPrefix(r, "zpool remove") {
|
|
f.after[r] = hook(mid)
|
|
} else {
|
|
f.after[r] = hook(c.after)
|
|
}
|
|
}
|
|
m := f.machine()
|
|
st := memStates{}
|
|
m.States = st
|
|
cfg, ref := ParseConfig([]byte(c.conf), 16000)
|
|
if len(ref) != 0 {
|
|
t.Fatal(c.name, ref)
|
|
}
|
|
err := m.ApplyDevices(context.Background(), cfg)
|
|
if (err != nil) != c.wantErr {
|
|
t.Errorf("%s: error %v", c.name, err)
|
|
}
|
|
for _, r := range c.ran {
|
|
if len(ran(f, r)) != 1 {
|
|
t.Errorf("%s: %q not run once; ran %v", c.name, r, f.calls)
|
|
}
|
|
}
|
|
for _, n := range c.notRan {
|
|
if len(ran(f, n)) != 0 {
|
|
t.Errorf("%s: ran %v", c.name, ran(f, n))
|
|
}
|
|
}
|
|
for _, call := range f.calls {
|
|
if strings.Contains(call, " -f") {
|
|
t.Errorf("%s: forced: %s", c.name, call)
|
|
}
|
|
}
|
|
if c.event == "" && len(f.emitted) != 0 {
|
|
t.Errorf("%s: events %v", c.name, f.emitted)
|
|
}
|
|
if c.event != "" && (len(f.emitted) != 1 || f.emitted[0]["_type"] != c.event) {
|
|
t.Errorf("%s: events %v, want %s", c.name, f.emitted, c.event)
|
|
}
|
|
if c.state != "" {
|
|
found := false
|
|
for k, v := range st {
|
|
if strings.Contains(string(v), `"outcome":"`+c.state+`"`) {
|
|
found = true
|
|
_ = k
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("%s: state %v, want outcome %s", c.name, st, c.state)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func orDefault(s, d string) string {
|
|
if s == "" {
|
|
return d
|
|
}
|
|
return s
|
|
}
|
|
|
|
// §17: a log-mirror change is said as a removal and a re-add.
|
|
func TestALogMirrorChangeIsSaidAsARemovalAndAReAdd(t *testing.T) {
|
|
f := newFake(t)
|
|
f.uid = 0
|
|
for _, n := range []string{"l1", "l2"} {
|
|
f.links["/dev/disk/by-id/"+n] = "/dev/" + n + "k"
|
|
}
|
|
before := devSpec{logs: [][][2]string{{dev("l1")}, {dev("l2")}}}
|
|
after := devSpec{logs: [][][2]string{{dev("l1"), dev("l2")}}}
|
|
before.set(f)
|
|
f.on("zpool status -P -L", "")
|
|
f.on("wipefs --no-act /dev/disk/by-id/l1", "")
|
|
f.on("wipefs --no-act /dev/disk/by-id/l2", "")
|
|
f.on("zpool remove -- tank /dev/disk/by-id/l1", "")
|
|
f.on("zpool remove -- tank /dev/disk/by-id/l2", "")
|
|
f.after["zpool remove -- tank /dev/disk/by-id/l2"] = func(f *fake) { devSpec{}.set(f) }
|
|
f.on("zpool add -- tank log mirror /dev/disk/by-id/l1 /dev/disk/by-id/l2", "")
|
|
f.after["zpool add -- tank log mirror /dev/disk/by-id/l1 /dev/disk/by-id/l2"] = func(f *fake) { after.set(f) }
|
|
m := f.machine()
|
|
m.States = memStates{}
|
|
cfg, _ := ParseConfig([]byte(`{"pool":"tank","log-devices":["l1","l2"],"log-mirror":true}`), 16000)
|
|
if err := m.ApplyDevices(context.Background(), cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if a, _ := f.emitted[0]["after"].(string); !strings.Contains(a, "a log-mirror change: a removal and a re-add") {
|
|
t.Errorf("event %v", f.emitted[0])
|
|
}
|
|
}
|