mirror of
https://github.com/henrygd/beszel.git
synced 2026-10-21 13:52:35 +00:00
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:
+29
-7
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user