package main import ( "context" "strings" "testing" "time" ) func TestDatasetsAreReadFromZfsListWithEverySizeInBytes(t *testing.T) { ds, err := ParseDatasets(fixture(t, "zfs-list.txt")) if err != nil { t.Fatal(err) } if len(ds) != 4 { t.Fatalf("datasets: %d", len(ds)) } media := find(ds, "tank/media") if media == nil || media.Mountpoint != "/storage/media" || media.Used != 27487790694400 || media.Available != 11203942195200 || media.Referenced != 26938034880512 || media.UsedBySnapshots != 549755813888 || media.ShareNFS != "rw=@192.0.2.0/24" || media.CompressRatio != 1.00 || media.Pool != "tank" || media.Compression != "lz4" { t.Errorf("tank/media: %+v", media) } if media.Quota != nil || media.Reservation != nil { t.Errorf("a quota of 0 under -p is none: %+v", media) } dl := find(ds, "tank/downloads") if dl.Quota == nil || *dl.Quota != 2199023255552 { t.Errorf("tank/downloads has a 2 TiB quota: %+v", dl.Quota) } vm := find(ds, "tank/vm") if vm.Type != "volume" || vm.Mountpoint != "" || vm.ShareNFS != "" || vm.CompressRatio != 1.85 || vm.RefReservation == nil || *vm.RefReservation != 4398046511 || vm.UsedByRefreservation != 2250562863 || vm.Quota != nil { t.Errorf("a volume has no mountpoint or sharenfs, and its refreservation is read: %+v", vm) } } func TestALineWithoutEveryColumnIsRefusedNotSkipped(t *testing.T) { if _, err := ParseDatasets("tank\tfilesystem\t/storage\n"); err == nil || !strings.Contains(err.Error(), "3 columns") { t.Errorf("a short line: %v", err) } if _, err := ParseDatasets(strings.Replace(fixture(t, "zfs-list.txt"), "27487790694400", "25.0T", 1)); err == nil { t.Errorf("a size that is not whole bytes is refused (the command lost its -p)") } if _, err := ParsePools("tank\t1\t2\n"); err == nil { t.Errorf("a short pool line is refused") } } func TestSnapshotsCarryTheirCreationTimeAndTheirOwnSpace(t *testing.T) { ss, err := ParseSnapshots(fixture(t, "zfs-list-snapshots.txt")) if err != nil { t.Fatal(err) } if len(ss) != 4 { t.Fatalf("snapshots: %d", len(ss)) } s := ss[0] if s.Dataset != "tank/media" || s.Snap != "2026-08-01" || !s.Created.Equal(time.Unix(1785542400, 0)) || s.Used != 274877906944 || s.Referenced != 26388279066624 { t.Errorf("first snapshot: %+v", s) } } func TestPoolsAreReadFromZpoolList(t *testing.T) { ps, err := ParsePools(fixture(t, "zpool-list.txt")) if err != nil { t.Fatal(err) } if len(ps) != 2 || ps[0].Name != "tank" || ps[0].Capacity != 60 || ps[0].Fragmentation == nil || *ps[0].Fragmentation != 3 || ps[0].Health != "ONLINE" || ps[0].Size != 47982397226598 || ps[0].Free != 19184805568512 { t.Fatalf("pools: %+v", ps) } if ps[1].Fragmentation != nil { t.Errorf("a pool whose fragmentation is '-' has none said: %v", *ps[1].Fragmentation) } } func TestStatusGivesEachPoolsLayoutWithSectionsAndTheLastScrub(t *testing.T) { st, err := ParseStatus(fixture(t, "zpool-status.txt")) if err != nil { t.Fatal(err) } if len(st) != 2 || st[0].Name != "rpool" || st[1].Name != "tank" { t.Fatalf("pools: %+v", st) } r := st[0] if !strings.HasPrefix(r.Status, "Some supported and requested features") || !strings.HasSuffix(r.Status, "some features are unavailable.") { t.Errorf("a status of two lines is one sentence: %q", r.Status) } tank := st[1] if tank.State != "ONLINE" || tank.Errors != "No known data errors" || tank.Root == nil || tank.Root.Kind != "pool" { t.Fatalf("tank: %+v", tank) } kids := tank.Root.Children if len(kids) != 4 { t.Fatalf("tank's top level is raidz2-0, logs, cache and spares: %+v", kids) } if kids[0].Name != "raidz2-0" || kids[0].Kind != "vdev" || kids[0].Role != "data" || len(kids[0].Children) != 4 || kids[0].Children[0].Name != "ata-WDC_WD120EFBX-68B0EN0_D7JA0001" || kids[0].Children[0].Kind != "device" { t.Errorf("raidz2-0: %+v", kids[0]) } if kids[1].Kind != "section" || kids[1].Role != "log" || len(kids[1].Children) != 1 || kids[1].Children[0].Name != "mirror-1" || len(kids[1].Children[0].Children) != 2 || kids[1].Children[0].Children[1].Role != "log" { t.Errorf("logs: %+v", kids[1]) } if kids[2].Role != "cache" || kids[2].Children[0].Kind != "device" || kids[2].Children[0].State != "ONLINE" { t.Errorf("cache: %+v", kids[2]) } if kids[3].Role != "spare" || kids[3].Children[0].State != "AVAIL" || kids[3].Children[0].Note != "" { t.Errorf("spares: %+v", kids[3].Children[0]) } s := tank.Scrub() end := time.Date(2026, 9, 14, 2, 34, 6, 0, time.Local) if s.Kind != "scrub" || s.State != "finished" || s.Took != "1 days 02:10:05" || s.Repaired != "0B" || deref(s.Errors) != 0 || s.Ended == nil || !s.Ended.Equal(end) || s.Started == nil || !s.Started.Equal(end.Add(-26*time.Hour-10*time.Minute-5*time.Second)) { t.Errorf("tank's scrub: %+v", s) } } func TestADegradedPoolSaysItsFaultedDeviceItsErrorsAndTheResilver(t *testing.T) { st, err := ParseStatus(fixture(t, "zpool-status-degraded.txt")) if err != nil { t.Fatal(err) } p := st[0] if p.State != "DEGRADED" || p.Action != "Wait for the resilver to complete." || p.Errors != "2 data errors, use '-v' for a list" { t.Fatalf("pool: %+v", p) } spare := p.Root.Children[0].Children[1] if spare.Name != "spare-1" || spare.Kind != "vdev" || len(spare.Children) != 2 { t.Fatalf("spare-1 holds the failed disk and its spare: %+v", spare) } bad := spare.Children[0] if bad.State != "FAULTED" || bad.Read != 12 || bad.Write != 107 || bad.Checksum != 3 || bad.Note != "too many errors" { t.Errorf("the faulted disk: %+v", bad) } if spare.Children[1].Note != "(resilvering)" { t.Errorf("the spare: %+v", spare.Children[1]) } if in := p.Root.Children[1].Children[0]; in.State != "INUSE" || in.Note != "currently in use" { t.Errorf("the spare in use: %+v", in) } s := p.Resilvers()[0] if s.Kind != "resilver" || s.State != "running" || s.Done == nil || *s.Done != 22.72 || s.ToGo != "08:25:13" || s.Started == nil || !s.Started.Equal(time.Date(2026, 10, 8, 21, 2, 17, 0, time.Local)) { t.Errorf("the resilver: %+v", s) } } func TestARunningScrubSaysItsProgress(t *testing.T) { st, err := ParseStatus(fixture(t, "zpool-status-scrubbing.txt")) if err != nil { t.Fatal(err) } s := st[0].Scrub() if s.Kind != "scrub" || s.State != "running" || *s.Done != 29.17 || s.ToGo != "06:49:34" || s.Repaired != "0B" { t.Errorf("scrub: %+v", s) } for _, said := range []string{"none requested", "scrub canceled on Sun Oct 4 01:00:00 2026", "scrub paused since Sun Oct 4 01:00:00 2026"} { if got := parseScan("scan", said); got.State == "unknown" { t.Errorf("%q not read: %+v", said, got) } } if got := parseScan("scan", "resilvered 1.01G in 03:22:11 with 0 errors on Fri Oct 9 06:00:00 2026"); got.Kind != "resilver" || got.State != "finished" || got.Repaired != "1.01G" { t.Errorf("a finished resilver: %+v", got) } } func TestTheHistoryGivesTheLastFinishedScrubAndResilverNotACanceledOne(t *testing.T) { scrub, resilver := ParseHistory(fixture(t, "zpool-history.txt")) if scrub == nil || !scrub.Ended.Equal(time.Date(2026, 7, 13, 2, 30, 0, 0, time.Local)) || deref(scrub.Errors) != 0 { t.Errorf("the last finished scrub is July's; September's was canceled: %+v", scrub) } if resilver == nil || !resilver.Ended.Equal(time.Date(2026, 8, 20, 19, 40, 2, 0, time.Local)) { t.Errorf("the last finished resilver: %+v", resilver) } } func TestIOSampleNamesEachLinesPoolAndKindFromTheLayout(t *testing.T) { st, _ := ParseStatus(fixture(t, "zpool-status.txt")) rows, err := ParseIO(fixture(t, "zpool-iostat.txt"), st) if err != nil { t.Fatal(err) } want := map[string]string{"tank": "pool", "raidz2-0": "vdev", "ata-WDC_WD120EFBX-68B0EN0_D7JA0001": "device", "mirror-1": "vdev", "rpool": "pool", "nvme-Samsung_SSD_970_EVO_Plus_500GB_S4EVNX0R200001-part2": "device"} for _, r := range rows { if k, ok := want[r.Name]; ok && r.Kind != k { t.Errorf("%s is a %s, said %s", r.Name, k, r.Kind) } } last := rows[len(rows)-1] if last.Pool != "rpool" || last.WriteOps != 25 || last.WriteBytes != 1310720 { t.Errorf("the last line is rpool's disk: %+v", last) } if rows[2].Allocated != nil || rows[0].ReadBytes != 221249536 { t.Errorf("a disk of a raidz has no allocation of its own: %+v / %+v", rows[2], rows[0]) } } func TestTheArcIsReadFromArcstats(t *testing.T) { f := newFake(t) f.files[ArcStats] = fixture(t, "arcstats.txt") got, err := f.machine().Arc() if err != nil { t.Fatal(err) } a := got.(ArcAnswer) if a.Size != 16798000128 || a.Target != 16823304192 || a.Max != 33646608384 || a.HitRatio != 90.5 || a.DemandHitRatio == nil || *a.DemandHitRatio != 93.0 || a.L2 == nil || a.L2.HitRatio != 25.0 || a.L2.Size != 412316860416 { t.Errorf("arc: %+v %+v", a, a.L2) } f.files[ArcStats] = fixture(t, "arcstats-nol2.txt") got, _ = f.machine().Arc() if a := got.(ArcAnswer); a.L2 != nil || a.HitRatio != 90 || !strings.Contains(strings.Join(a.Said, " "), "no L2ARC") { t.Errorf("without a cache device: %+v", a) } } func TestEventsAreErrorsAndChangesOfStateNewestFirstAsRoot(t *testing.T) { f := newFake(t) f.on("sudo -n zpool events -v", fixture(t, "zpool-events.txt")) got, err := f.machine().Events(context.Background(), false, 50) if err != nil { t.Fatal(err) } a := got.(EventsAnswer) if a.Kept != 5 || a.Shown != 3 { t.Fatalf("kept %d, shown %d: %+v", a.Kept, a.Shown, a.Events) } if a.Events[0].Class != "sysevent.fs.zfs.resilver_start" || a.Events[1].Class != "resource.fs.zfs.statechange" || a.Events[2].Class != "ereport.fs.zfs.io" { t.Errorf("newest first, history and config_sync left out: %+v", a.Events) } sc := a.Events[1].Details if sc["vdev_state"] != "FAULTED" || sc["vdev_laststate"] != "ONLINE" || sc["pool"] != "tank" || sc["vdev_path"] != "/dev/disk/by-id/ata-WDC_WD120EFBX-68B0EN0_D7JA0002-part1" { t.Errorf("the state change: %+v", sc) } if _, kept := a.Events[2].Details["ena"]; kept { t.Errorf("internal identifiers are left out: %+v", a.Events[2].Details) } got, _ = f.machine().Events(context.Background(), true, 2) if a := got.(EventsAnswer); a.Shown != 2 || a.Events[1].Class != "sysevent.fs.zfs.config_sync" { t.Errorf("all, limited to 2: %+v", a.Events) } } func TestPropertiesWithholdWhereTheKeyIsAndValidateTheDataset(t *testing.T) { f := withZFS(t) f.on(line("zfs", PropertiesArgv("tank/media")), fixture(t, "zfs-get.txt")) got, err := f.machine().PropertiesOf(context.Background(), "tank/media") if err != nil { t.Fatal(err) } a := got.(PropertiesAnswer) by := map[string]Property{} for _, p := range a.Properties { by[p.Name] = p } if strings.Contains(by["keylocation"].Value, "s3cret") || by["encryption"].Value != "aes-256-gcm" || by["keystatus"].Value != "available" || by["tank:purpose"].Value != "the library" || by["recordsize"].Source != "local" { t.Errorf("properties: %+v", by) } said := strings.Join(a.Said, "\n") if !strings.Contains(said, "compression = lz4 (inherited from tank)") || strings.Contains(said, "s3cret") { t.Errorf("said: %s", said) } if _, err := f.machine().PropertiesOf(context.Background(), "tank/nope"); err == nil { t.Errorf("an unknown dataset is refused") } if n := len(ran(f, "zfs get")); n != 1 { t.Errorf("zfs get runs only for a dataset that exists: %d", n) } if argv := PropertiesArgv("tank/media"); argv[len(argv)-2] != "--" { t.Errorf("the dataset follows --: %v", argv) } } func TestDatasetsCountEachOnesSnapshots(t *testing.T) { got, err := withZFS(t).machine().DatasetsNow(context.Background()) if err != nil { t.Fatal(err) } a := got.(DatasetsAnswer) if a.Count != 4 || a.Datasets[1].Name != "tank/media" || a.Datasets[1].Snapshots != 3 || a.Datasets[2].Snapshots != 1 || a.Datasets[0].Snapshots != 0 || a.Datasets[1].UsedBySnapshots != 549755813888 { t.Errorf("datasets: %+v", a.Datasets) } if !strings.Contains(a.Said[1], "3 snapshots holding 512.0 GiB") || !strings.Contains(a.Said[1], "sharenfs rw=@192.0.2.0/24") { t.Errorf("said: %s", a.Said[1]) } } func TestSpaceIsBiggestFirstWithTheBreakdownAndTheSavings(t *testing.T) { got, err := withZFS(t).machine().Space(context.Background()) if err != nil { t.Fatal(err) } a := got.(SpaceAnswer) if len(a.Pools) != 2 || a.Pools[0].Capacity != 60 { t.Errorf("pools: %+v", a.Pools) } if a.Datasets[0].Name != "tank" || a.Datasets[1].Name != "tank/media" || a.Datasets[3].Name != "tank/vm" { t.Errorf("biggest first: %v, %v, %v", a.Datasets[0].Name, a.Datasets[1].Name, a.Datasets[3].Name) } media := a.Datasets[1] if media.Saved != 27500000000000-27487790694400 || media.UsedBySnapshots != 549755813888 { t.Errorf("media: %+v", media) } if dl := a.Datasets[2]; dl.Quota == nil || !strings.Contains(dl.Said, "quota 2.0 TiB") { t.Errorf("downloads: %+v", dl) } } func TestSnapshotsOfOneDatasetAreItsOwnOnly(t *testing.T) { f := withZFS(t) got, err := f.machine().SnapshotsOf(context.Background(), "tank/media") if err != nil { t.Fatal(err) } a := got.(SnapshotsAnswer) if a.Count != 3 || a.Snapshots[2].Name != "tank/media@before-rename" || a.Unique != 274877906944+137438953472+1048576 { t.Errorf("snapshots: %+v", a) } if _, err := f.machine().SnapshotsOf(context.Background(), "tank/med"); err == nil { t.Errorf("an unknown dataset is refused") } got, _ = f.machine().SnapshotsOf(context.Background(), "") if got.(SnapshotsAnswer).Count != 4 { t.Errorf("every snapshot: %+v", got) } } func TestLayoutAndPoolsSayDeviceErrorsByName(t *testing.T) { f := newFake(t) f.on(line("zpool", PoolsArgv()), fixture(t, "zpool-list-full.txt")) f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt")) got, err := f.machine().Layout(context.Background()) if err != nil { t.Fatal(err) } said := strings.Join(got.(LayoutAnswer).Said, "\n") if !strings.Contains(said, "ata-WDC_WD120EFBX-68B0EN0_D7JA0002 FAULTED — ERRORS read 12, write 107, checksum 3 too many errors") { t.Errorf("layout: %s", said) } got, err = f.machine().PoolsNow(context.Background()) if err != nil { t.Fatal(err) } p := got.(PoolsAnswer).Pools[0] if p.State != "DEGRADED" || p.DeviceErrs != 122 || p.Capacity != 91 || !strings.HasPrefix(p.Scan, "resilver in progress") { t.Errorf("pool: %+v", p) } } func TestIOAndScrubAnswerFromFakes(t *testing.T) { f := withZFS(t) f.on(line("zpool", IOArgv(3)), fixture(t, "zpool-iostat.txt")) got, err := f.machine().IO(context.Background(), 3) if err != nil { t.Fatal(err) } if a := got.(IOAnswer); len(a.Rows) != 10 || a.Seconds != 3 { t.Errorf("io: %+v", a) } if argv := IOArgv(3); strings.Join(argv, " ") != "iostat -y -Hpv 3 1" { t.Errorf("argv: %v", argv) } got, err = f.machine().Scrubs(context.Background(), false) if err != nil { t.Fatal(err) } a := got.(ScrubAnswer) rp, tank := a.Pools[0], a.Pools[1] if tank.DaysSince == nil || *tank.DaysSince != 25.4 || tank.Warning != "" { t.Errorf("tank was scrubbed 25 days ago, and has no schedule here: %+v", tank) } if rp.Warning == "" || !strings.Contains(strings.Join(rp.Said, " "), "WARNING") { t.Errorf("rpool was scrubbed in July, more than 35 days ago: %+v", rp) } if len(ran(f, "sudo")) != 0 { t.Errorf("without history nothing runs as root: %v", f.calls) } } func TestScrubWithHistoryReadsItAsRootAndFindsTheScrubStatusNoLongerShows(t *testing.T) { f := newFake(t) f.on(line("zpool", StatusArgv()), fixture(t, "zpool-status-degraded.txt")) f.on("sudo -n zpool history -i tank", fixture(t, "zpool-history.txt")) got, err := f.machine().Scrubs(context.Background(), true) if err != nil { t.Fatal(err) } r := got.(ScrubAnswer).Pools[0] if r.LastScrub == nil || !r.LastScrub.Ended.Equal(time.Date(2026, 7, 13, 2, 30, 0, 0, time.Local)) || r.Warning == "" { t.Errorf("the last scrub, from the history, is 88 days old: %+v", r) } if r.LastResilver == nil || r.Scans[0].State != "running" { t.Errorf("the resilver: %+v", r) } }