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)