From 32d469285382521bae4ad3f368a7045f32ff2f5e Mon Sep 17 00:00:00 2001 From: kenny-ish <114967656+kenny-ish@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:14:19 +0700 Subject: [PATCH] fix(agent): report ZFS pool capacity from the root dataset (#2540) The pool overview used `zpool list`, whose size and alloc count raw vdev space. On raidz that includes parity, so a TrueNAS pool with 65 TB usable showed 98 TB capacity, while the details page (root dataset) matched TrueNAS. Take Size, Alloc and Free from the pool's root dataset (used+avail), the same way Btrfs reports effective capacity. If the root dataset cannot be read, keep the raw figures and set Raw so the UI labels them as raw usage. Fixes #2539 --- agent/zfs/zfs.go | 34 +++++++++++++++++++++++-- agent/zfs/zfs_linux_test.go | 51 +++++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 7 deletions(-) diff --git a/agent/zfs/zfs.go b/agent/zfs/zfs.go index 0cd224d6b..2806f82a0 100644 --- a/agent/zfs/zfs.go +++ b/agent/zfs/zfs.go @@ -68,7 +68,8 @@ type Dataset struct { } // PoolStats returns capacity and health for all pools on the system using -// `zpool list`. Frequent health and I/O sampling uses PoolKernelStats instead. +// `zpool list`, with capacity taken from each pool's root dataset where it can +// be read. Frequent health and I/O sampling uses PoolKernelStats instead. func PoolStats() ([]PoolStat, error) { if err := checkZfsDevice(); err != nil { return nil, err @@ -81,7 +82,36 @@ func PoolStats() ([]PoolStat, error) { } return nil, fmt.Errorf("zpool list: %w", err) } - return parseZpoolListOutput(out) + pools, err := parseZpoolListOutput(out) + if err != nil || len(pools) == 0 { + return pools, err + } + applyRootDatasetCapacity(pools) + return pools, nil +} + +// applyRootDatasetCapacity replaces the raw `zpool list` figures, which include +// parity on raidz and dRAID vdevs, with each pool's root dataset used and avail. +// As with Btrfs, the effective capacity is used+avail, so Size-Alloc is what +// applications can still write and the usage ratio matches df. A pool whose +// root dataset cannot be read keeps its raw figures and is flagged as Raw. +func applyRootDatasetCapacity(pools []PoolStat) { + roots := make(map[string]Dataset) + if out, err := commandOutput("zfs", "list", "-Hp", "-d", "0", "-o", "name,used,avail,mountpoint"); err == nil { + if datasets, err := parseZfsListOutput(out); err == nil { + for _, ds := range datasets { + roots[ds.Name] = ds + } + } + } + for i := range pools { + root, ok := roots[pools[i].Name] + if !ok { + pools[i].Raw = true + continue + } + pools[i].Size, pools[i].Alloc, pools[i].Free = root.Used+root.Avail, root.Used, root.Avail + } } // Datasets returns all datasets on the system with usage and mountpoint diff --git a/agent/zfs/zfs_linux_test.go b/agent/zfs/zfs_linux_test.go index 41f216ba6..c218e1180 100644 --- a/agent/zfs/zfs_linux_test.go +++ b/agent/zfs/zfs_linux_test.go @@ -3,6 +3,7 @@ package zfs import ( + "errors" "os" "path/filepath" "testing" @@ -260,15 +261,55 @@ func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) { oldCommandOutput := commandOutput called := false commandOutput = func(name string, args ...string) ([]byte, error) { - called = true - assert.Equal(t, "zpool", name) - assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args) - return []byte("tank\t100\t50\t50\tONLINE\n"), nil + switch name { + case "zpool": + called = true + assert.Equal(t, []string{"list", "-Hp", "-o", "name,size,alloc,free,health"}, args) + // raidz: the raw figures include parity + return []byte("tank\t150\t60\t90\tONLINE\n"), nil + case "zfs": + assert.Equal(t, []string{"list", "-Hp", "-d", "0", "-o", "name,used,avail,mountpoint"}, args) + return []byte("tank\t40\t60\t/tank\n"), nil + } + t.Fatalf("unexpected %s call with %v", name, args) + return nil, nil } t.Cleanup(func() { commandOutput = oldCommandOutput }) pools, err := PoolStats() require.NoError(t, err) assert.True(t, called) - assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 50, Free: 50, Health: "ONLINE"}}, pools) + assert.Equal(t, []PoolStat{{Name: "tank", Size: 100, Alloc: 40, Free: 60, Health: "ONLINE"}}, pools) +} + +func TestPoolStatsKeepsRawCapacityWithoutRootDataset(t *testing.T) { + devFile := filepath.Join(t.TempDir(), "zfs") + require.NoError(t, os.WriteFile(devFile, nil, 0o644)) + oldDevZfsPath := devZfsPath + devZfsPath = devFile + t.Cleanup(func() { devZfsPath = oldDevZfsPath }) + + for _, tc := range []struct { + name string + zfsOut []byte + zfsErr error + }{ + {"zfs list fails", nil, errors.New("zfs list failed")}, + {"root dataset missing", []byte("other\t1\t1\t/other\n"), nil}, + } { + t.Run(tc.name, func(t *testing.T) { + oldCommandOutput := commandOutput + commandOutput = func(name string, args ...string) ([]byte, error) { + if name == "zpool" { + return []byte("tank\t150\t60\t90\tONLINE\n"), nil + } + return tc.zfsOut, tc.zfsErr + } + t.Cleanup(func() { commandOutput = oldCommandOutput }) + + pools, err := PoolStats() + require.NoError(t, err) + assert.Equal(t, []PoolStat{{Name: "tank", Size: 150, Alloc: 60, Free: 90, Health: "ONLINE", Raw: true}}, pools) + }) + } }