From bb23f99b3eeaced9608e11e280e7c986dba650be Mon Sep 17 00:00:00 2001 From: henrygd Date: Mon, 12 Oct 2026 14:17:59 -0400 Subject: [PATCH] fix(agent): keep raw ZFS pool capacity when the root dataset has a refquota A refquota on a pool's root dataset caps its avail by the root's own referenced data, so used+avail can understate pool capacity when child datasets hold most of the data and trigger false disk usage alerts. Fetch refquota with the root datasets and fall back to the raw zpool figures for those pools. --- agent/zfs/zfs.go | 36 +++++++++++++++++++++++++++------- agent/zfs/zfs_linux_test.go | 8 +++++--- agent/zfs/zfs_nonlinux_test.go | 6 +++++- agent/zfs/zfs_test.go | 14 +++++++++++++ 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/agent/zfs/zfs.go b/agent/zfs/zfs.go index 2806f82a0..011a0804a 100644 --- a/agent/zfs/zfs.go +++ b/agent/zfs/zfs.go @@ -96,13 +96,9 @@ func PoolStats() ([]PoolStat, error) { // 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 - } - } + var roots map[string]Dataset + if out, err := commandOutput("zfs", "list", "-Hp", "-d", "0", "-o", "name,used,avail,refquota"); err == nil { + roots = parseRootDatasetOutput(out) } for i := range pools { root, ok := roots[pools[i].Name] @@ -114,6 +110,32 @@ func applyRootDatasetCapacity(pools []PoolStat) { } } +// parseRootDatasetOutput parses `zfs list -Hp -d 0 -o name,used,avail,refquota` +// output into root datasets keyed by pool name. Roots with a refquota are left +// out: the quota caps avail by the root's own data rather than the pool's free +// space, which would understate capacity when children hold most of the data. +// Malformed lines are skipped, so their pools fall back to raw figures. +func parseRootDatasetOutput(out []byte) map[string]Dataset { + roots := make(map[string]Dataset) + scanner := bufio.NewScanner(bytes.NewReader(out)) + for scanner.Scan() { + fields := strings.Split(strings.TrimSpace(scanner.Text()), "\t") + if len(fields) != 4 || (fields[3] != "0" && fields[3] != "none") { + continue + } + used, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + avail, err := strconv.ParseUint(fields[2], 10, 64) + if err != nil { + continue + } + roots[fields[0]] = Dataset{Name: fields[0], Used: used, Avail: avail} + } + return roots +} + // Datasets returns all datasets on the system with usage and mountpoint // information using `zfs list` (recursive by default). func Datasets() ([]Dataset, error) { diff --git a/agent/zfs/zfs_linux_test.go b/agent/zfs/zfs_linux_test.go index c218e1180..96a97cca3 100644 --- a/agent/zfs/zfs_linux_test.go +++ b/agent/zfs/zfs_linux_test.go @@ -268,8 +268,8 @@ func TestPoolStatsDelegatesToZpoolWhenDevZfsPresent(t *testing.T) { // 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 + assert.Equal(t, []string{"list", "-Hp", "-d", "0", "-o", "name,used,avail,refquota"}, args) + return []byte("tank\t40\t60\t0\n"), nil } t.Fatalf("unexpected %s call with %v", name, args) return nil, nil @@ -295,7 +295,9 @@ func TestPoolStatsKeepsRawCapacityWithoutRootDataset(t *testing.T) { zfsErr error }{ {"zfs list fails", nil, errors.New("zfs list failed")}, - {"root dataset missing", []byte("other\t1\t1\t/other\n"), nil}, + {"root dataset missing", []byte("other\t1\t1\t0\n"), nil}, + // a refquota caps the root's avail below the pool's free space + {"root dataset has refquota", []byte("tank\t40\t10\t20\n"), nil}, } { t.Run(tc.name, func(t *testing.T) { oldCommandOutput := commandOutput diff --git a/agent/zfs/zfs_nonlinux_test.go b/agent/zfs/zfs_nonlinux_test.go index b7e71c42e..5c0529513 100644 --- a/agent/zfs/zfs_nonlinux_test.go +++ b/agent/zfs/zfs_nonlinux_test.go @@ -3,6 +3,7 @@ package zfs import ( + "slices" "testing" "github.com/stretchr/testify/assert" @@ -16,6 +17,9 @@ func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) { case "zpool": return []byte("tank\t100\t50\t50\tONLINE\n"), nil case "zfs": + if slices.Contains(args, "-d") { + return []byte("tank\t40\t60\tnone\n"), nil + } return []byte("tank\t50\t50\t/tank\n"), nil default: t.Fatalf("unexpected command %s", name) @@ -26,7 +30,7 @@ func TestCollectorsUseUtilitiesOnNonLinux(t *testing.T) { pools, err := PoolStats() require.NoError(t, err) - 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) datasets, err := Datasets() require.NoError(t, err) assert.Equal(t, []Dataset{{Name: "tank", Used: 50, Avail: 50, Mountpoint: "/tank"}}, datasets) diff --git a/agent/zfs/zfs_test.go b/agent/zfs/zfs_test.go index db0fbd467..f1a524c17 100644 --- a/agent/zfs/zfs_test.go +++ b/agent/zfs/zfs_test.go @@ -67,6 +67,20 @@ func TestParseZfsListOutput(t *testing.T) { assert.Equal(t, uint64(11999000000000), datasets[0].Avail) } +func TestParseRootDatasetOutput(t *testing.T) { + roots := parseRootDatasetOutput([]byte( + "tank\t40\t60\t0\n" + + "backup\t5\t15\tnone\n" + + "quota\t40\t10\t20\n" + + "broken\tnotanumber\t60\t0\n" + + "short\t40\t60\n", + )) + assert.Equal(t, map[string]Dataset{ + "tank": {Name: "tank", Used: 40, Avail: 60}, + "backup": {Name: "backup", Used: 5, Avail: 15}, + }, roots) +} + func TestParseZpoolStatusOutput(t *testing.T) { data, err := os.ReadFile(fixturePath("zpool_status.txt")) require.NoError(t, err)