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