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.
This commit is contained in:
henrygd
2026-10-12 14:17:59 -04:00
parent 32d4692853
commit bb23f99b3e
4 changed files with 53 additions and 11 deletions
+29 -7
View File
@@ -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) {
+5 -3
View File
@@ -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
+5 -1
View File
@@ -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)
+14
View File
@@ -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)