Files
mesh-catalog/modules/zfs/cmd/zfs-tools/apply_devices_test.go
T

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