Pulse/internal/hostmetrics/zfs_test.go
rcourtman bb798e18b6 fix(agent): find zpool binary on TrueNAS SCALE (#718)
Enhanced zpool binary lookup to try common paths when exec.LookPath fails.
This fixes issue #718 where TrueNAS SCALE reports inflated storage because
the agent runs with a restricted PATH that doesn't include /usr/sbin.

Changes:
- Added findZpool() helper that tries common paths like /usr/sbin/zpool,
  /sbin/zpool, /usr/local/sbin/zpool for TrueNAS/FreeBSD/Linux systems
- Added commonZpoolPaths variable listing typical zpool locations
- Added tests for the new findZpool function

This ensures zpool list is used for accurate pool-level capacity instead
of falling back to dataset-level summation.
2025-12-18 16:23:56 +00:00

812 lines
20 KiB
Go

package hostmetrics
import (
"context"
"os"
"testing"
)
func TestSummarizeZFSPoolsUsesZpoolStats(t *testing.T) {
originalQuery := queryZpoolStats
t.Cleanup(func() { queryZpoolStats = originalQuery })
queryZpoolStats = func(ctx context.Context, pools []string) (map[string]zpoolStats, error) {
return map[string]zpoolStats{
"bpool": {Size: 1000, Alloc: 400, Free: 600},
"tank": {Size: 5000, Alloc: 2000, Free: 3000},
}, nil
}
datasets := []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Mountpoint: "/mnt/tank", Total: 5000, Used: 2000, Free: 3000},
{Pool: "tank", Dataset: "tank/home", Mountpoint: "/mnt/tank/home", Total: 4500, Used: 1500, Free: 3000},
{Pool: "bpool", Dataset: "bpool/ROOT/debian", Mountpoint: "/boot", Total: 800, Used: 200, Free: 600},
}
disks := summarizeZFSPools(context.Background(), datasets)
if len(disks) != 2 {
t.Fatalf("expected 2 disks, got %d", len(disks))
}
if disks[0].Device != "bpool" || disks[1].Device != "tank" {
t.Fatalf("unexpected disk order: %+v", disks)
}
tank := disks[1]
if tank.Mountpoint != "/mnt/tank" {
t.Errorf("expected tank mountpoint /mnt/tank, got %s", tank.Mountpoint)
}
if tank.TotalBytes != 5000 || tank.UsedBytes != 2000 || tank.FreeBytes != 3000 {
t.Errorf("unexpected tank capacity %+v", tank)
}
if tank.Usage < 39.9 || tank.Usage > 40.1 {
t.Errorf("expected tank usage ~40%%, got %.2f", tank.Usage)
}
}
func TestSummarizeZFSPoolsFallback(t *testing.T) {
originalQuery := queryZpoolStats
t.Cleanup(func() { queryZpoolStats = originalQuery })
queryZpoolStats = func(ctx context.Context, pools []string) (map[string]zpoolStats, error) {
return nil, context.DeadlineExceeded
}
datasets := []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Mountpoint: "/mnt/tank", Total: 5000, Used: 2000, Free: 3000},
{Pool: "tank", Dataset: "tank/home", Mountpoint: "/mnt/tank/home", Total: 4500, Used: 1500, Free: 3000},
{Pool: "bpool", Dataset: "bpool/ROOT/debian", Mountpoint: "/boot", Total: 800, Used: 200, Free: 600},
}
disks := summarizeZFSPools(context.Background(), datasets)
if len(disks) != 2 {
t.Fatalf("expected 2 disks, got %d", len(disks))
}
tank := disks[1]
if tank.TotalBytes != 5000 || tank.UsedBytes != 2000 || tank.FreeBytes != 3000 {
t.Errorf("tank fallback totals incorrect: %+v", tank)
}
if tank.Usage < 39.9 || tank.Usage > 40.1 {
t.Errorf("expected tank usage ~40%%, got %.2f", tank.Usage)
}
}
func TestFindZpool(t *testing.T) {
// This test verifies that findZpool correctly:
// 1. Uses exec.LookPath first (if zpool is in PATH)
// 2. Falls back to common absolute paths
// We can't easily mock os.Stat, so we just verify the function
// returns a path (if zpool is installed) or an error (if not)
path, err := findZpool()
// On a system without zpool, we expect an error
if err != nil {
if path != "" {
t.Errorf("findZpool() returned path %q with error: %v", path, err)
}
// Expected behavior on systems without zpool
return
}
// On a system with zpool, verify the path looks valid
if path == "" {
t.Error("findZpool() returned empty path without error")
}
// Verify the returned path exists
if _, statErr := os.Stat(path); statErr != nil {
t.Errorf("findZpool() returned path %q but os.Stat failed: %v", path, statErr)
}
}
func TestCommonZpoolPaths(t *testing.T) {
// Verify that commonZpoolPaths contains expected paths for TrueNAS
expectedPaths := []string{
"/usr/sbin/zpool", // TrueNAS SCALE, Debian, Ubuntu
"/sbin/zpool", // FreeBSD, older Linux
"/usr/local/sbin/zpool", // FreeBSD ports
}
for _, expected := range expectedPaths {
found := false
for _, path := range commonZpoolPaths {
if path == expected {
found = true
break
}
}
if !found {
t.Errorf("commonZpoolPaths missing expected path: %s", expected)
}
}
}
func TestZFSPoolFromDevice(t *testing.T) {
tests := []struct {
device string
want string
}{
{device: "tank/ROOT/default", want: "tank"},
{device: "vault", want: "vault"},
{device: "", want: ""},
}
for _, tt := range tests {
got := zfsPoolFromDevice(tt.device)
if got != tt.want {
t.Errorf("zfsPoolFromDevice(%q) = %q, want %q", tt.device, got, tt.want)
}
}
}
func TestParseZpoolList(t *testing.T) {
tests := []struct {
name string
input string
wantPools []string
wantStats map[string]zpoolStats
wantErr bool
errContains string
}{
{
name: "single pool",
input: "rpool\t1000000000\t500000000\t500000000\n",
wantPools: []string{"rpool"},
wantStats: map[string]zpoolStats{
"rpool": {Size: 1000000000, Alloc: 500000000, Free: 500000000},
},
},
{
name: "multiple pools",
input: "rpool\t1000000000\t500000000\t500000000\n" +
"tank\t2000000000\t1000000000\t1000000000\n",
wantPools: []string{"rpool", "tank"},
wantStats: map[string]zpoolStats{
"rpool": {Size: 1000000000, Alloc: 500000000, Free: 500000000},
"tank": {Size: 2000000000, Alloc: 1000000000, Free: 1000000000},
},
},
{
name: "empty output",
input: "",
wantErr: true,
errContains: "no usable data",
},
{
name: "whitespace only",
input: " \n\t\n ",
wantErr: true,
errContains: "no usable data",
},
{
name: "pool with zero usage",
input: "empty\t1000000000\t0\t1000000000\n",
wantPools: []string{"empty"},
wantStats: map[string]zpoolStats{
"empty": {Size: 1000000000, Alloc: 0, Free: 1000000000},
},
},
{
name: "pool fully used",
input: "full\t1000000000\t1000000000\t0\n",
wantPools: []string{"full"},
wantStats: map[string]zpoolStats{
"full": {Size: 1000000000, Alloc: 1000000000, Free: 0},
},
},
{
name: "skip malformed line too few fields",
input: "rpool\t1000\n",
wantErr: true,
errContains: "no usable data",
},
{
name: "skip line with invalid size",
input: "bad\tnotanumber\t500\t500\n" +
"good\t1000\t500\t500\n",
wantPools: []string{"good"},
wantStats: map[string]zpoolStats{
"good": {Size: 1000, Alloc: 500, Free: 500},
},
},
{
name: "skip line with invalid alloc",
input: "bad\t1000\tnotanumber\t500\n" +
"good\t1000\t500\t500\n",
wantPools: []string{"good"},
wantStats: map[string]zpoolStats{
"good": {Size: 1000, Alloc: 500, Free: 500},
},
},
{
name: "skip line with invalid free",
input: "bad\t1000\t500\tnotanumber\n" +
"good\t1000\t500\t500\n",
wantPools: []string{"good"},
wantStats: map[string]zpoolStats{
"good": {Size: 1000, Alloc: 500, Free: 500},
},
},
{
name: "large values near uint64 max",
input: "huge\t18446744073709551615\t9223372036854775808\t9223372036854775807\n",
wantPools: []string{"huge"},
wantStats: map[string]zpoolStats{
"huge": {Size: 18446744073709551615, Alloc: 9223372036854775808, Free: 9223372036854775807},
},
},
{
name: "extra fields ignored",
input: "pool\t1000\t500\t500\textra\tmore\n",
wantPools: []string{"pool"},
wantStats: map[string]zpoolStats{
"pool": {Size: 1000, Alloc: 500, Free: 500},
},
},
{
name: "pool name with hyphen",
input: "my-pool\t1000\t500\t500\n",
wantPools: []string{"my-pool"},
wantStats: map[string]zpoolStats{
"my-pool": {Size: 1000, Alloc: 500, Free: 500},
},
},
{
name: "pool name with underscore",
input: "my_pool\t1000\t500\t500\n",
wantPools: []string{"my_pool"},
wantStats: map[string]zpoolStats{
"my_pool": {Size: 1000, Alloc: 500, Free: 500},
},
},
{
name: "blank lines interspersed",
input: "\npool1\t1000\t500\t500\n\n" +
"pool2\t2000\t1000\t1000\n\n",
wantPools: []string{"pool1", "pool2"},
wantStats: map[string]zpoolStats{
"pool1": {Size: 1000, Alloc: 500, Free: 500},
"pool2": {Size: 2000, Alloc: 1000, Free: 1000},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseZpoolList([]byte(tt.input))
if tt.wantErr {
if err == nil {
t.Errorf("parseZpoolList() error = nil, want error containing %q", tt.errContains)
return
}
if tt.errContains != "" && !containsSubstr(err.Error(), tt.errContains) {
t.Errorf("parseZpoolList() error = %q, want error containing %q", err.Error(), tt.errContains)
}
return
}
if err != nil {
t.Errorf("parseZpoolList() unexpected error = %v", err)
return
}
for _, pool := range tt.wantPools {
stat, ok := got[pool]
if !ok {
t.Errorf("parseZpoolList() missing pool %q", pool)
continue
}
want := tt.wantStats[pool]
if stat != want {
t.Errorf("parseZpoolList() pool %q = %+v, want %+v", pool, stat, want)
}
}
if len(got) != len(tt.wantPools) {
t.Errorf("parseZpoolList() got %d pools, want %d", len(got), len(tt.wantPools))
}
})
}
}
func TestUniqueZFSPools(t *testing.T) {
tests := []struct {
name string
datasets []zfsDatasetUsage
want []string
}{
{
name: "empty input",
datasets: nil,
want: nil,
},
{
name: "empty slice",
datasets: []zfsDatasetUsage{},
want: nil,
},
{
name: "single pool single dataset",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
},
want: []string{"tank"},
},
{
name: "single pool multiple datasets",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
{Pool: "tank", Dataset: "tank/data", Mountpoint: "/tank/data"},
{Pool: "tank", Dataset: "tank/home", Mountpoint: "/home"},
},
want: []string{"tank"},
},
{
name: "multiple pools",
datasets: []zfsDatasetUsage{
{Pool: "rpool", Dataset: "rpool", Mountpoint: "/"},
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
{Pool: "backup", Dataset: "backup", Mountpoint: "/backup"},
},
want: []string{"backup", "rpool", "tank"}, // sorted
},
{
name: "skip empty pool names",
datasets: []zfsDatasetUsage{
{Pool: "", Dataset: "orphan", Mountpoint: "/orphan"},
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
},
want: []string{"tank"},
},
{
name: "all empty pool names",
datasets: []zfsDatasetUsage{
{Pool: "", Dataset: "orphan1", Mountpoint: "/orphan1"},
{Pool: "", Dataset: "orphan2", Mountpoint: "/orphan2"},
},
want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := uniqueZFSPools(tt.datasets)
if !stringSliceEqual(got, tt.want) {
t.Errorf("uniqueZFSPools() = %v, want %v", got, tt.want)
}
})
}
}
func TestBestZFSMountpoints(t *testing.T) {
tests := []struct {
name string
datasets []zfsDatasetUsage
want map[string]string
}{
{
name: "empty input",
datasets: nil,
want: map[string]string{},
},
{
name: "single dataset",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
},
want: map[string]string{"tank": "/tank"},
},
{
name: "prefer root dataset over child",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank/data", Mountpoint: "/tank/data"},
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
},
want: map[string]string{"tank": "/tank"},
},
{
name: "prefer shallower mountpoint",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank/a/b/c", Mountpoint: "/a/b/c/d"},
{Pool: "tank", Dataset: "tank/x", Mountpoint: "/x"},
},
want: map[string]string{"tank": "/x"},
},
{
name: "root mountpoint preferred",
datasets: []zfsDatasetUsage{
{Pool: "rpool", Dataset: "rpool/ROOT/ubuntu", Mountpoint: "/"},
{Pool: "rpool", Dataset: "rpool/home", Mountpoint: "/home"},
},
want: map[string]string{"rpool": "/"},
},
{
name: "skip empty pool",
datasets: []zfsDatasetUsage{
{Pool: "", Dataset: "orphan", Mountpoint: "/orphan"},
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
},
want: map[string]string{"tank": "/tank"},
},
{
name: "skip empty mountpoint",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Mountpoint: ""},
{Pool: "tank", Dataset: "tank/data", Mountpoint: "/data"},
},
want: map[string]string{"tank": "/data"},
},
{
name: "multiple pools",
datasets: []zfsDatasetUsage{
{Pool: "rpool", Dataset: "rpool", Mountpoint: "/"},
{Pool: "tank", Dataset: "tank", Mountpoint: "/tank"},
{Pool: "backup", Dataset: "backup/daily", Mountpoint: "/backup/daily"},
},
want: map[string]string{
"rpool": "/",
"tank": "/tank",
"backup": "/backup/daily",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := bestZFSMountpoints(tt.datasets)
if len(got) != len(tt.want) {
t.Errorf("bestZFSMountpoints() got %d entries, want %d", len(got), len(tt.want))
}
for pool, wantMount := range tt.want {
gotMount, ok := got[pool]
if !ok {
t.Errorf("bestZFSMountpoints() missing pool %q", pool)
continue
}
if gotMount != wantMount {
t.Errorf("bestZFSMountpoints()[%q] = %q, want %q", pool, gotMount, wantMount)
}
}
})
}
}
func TestZfsMountpointScore(t *testing.T) {
tests := []struct {
name string
ds zfsDatasetUsage
want int
}{
{
name: "root dataset gets score 0",
ds: zfsDatasetUsage{Dataset: "tank", Mountpoint: "/tank"},
want: 0,
},
{
name: "child dataset at root mountpoint",
ds: zfsDatasetUsage{Dataset: "rpool/ROOT/ubuntu", Mountpoint: "/"},
want: 1, // path empty after trim("/"), returns 1
},
{
name: "child dataset shallow path",
ds: zfsDatasetUsage{Dataset: "tank/data", Mountpoint: "/data"},
want: 1, // path="data" has 0 slashes, 1+0=1
},
{
name: "child dataset deep path",
ds: zfsDatasetUsage{Dataset: "tank/a/b/c", Mountpoint: "/a/b/c"},
want: 3, // path="a/b/c" has 2 slashes, 1+2=3
},
{
name: "empty dataset falls through to path scoring",
ds: zfsDatasetUsage{Dataset: "", Mountpoint: "/tank"},
want: 1, // empty dataset passes first check, path="tank" has 0 slashes, 1+0=1
},
{
name: "trailing slash stripped from mountpoint",
ds: zfsDatasetUsage{Dataset: "tank/data", Mountpoint: "/data/"},
want: 1, // path="data" has 0 slashes, 1+0=1
},
{
name: "very deep mountpoint",
ds: zfsDatasetUsage{Dataset: "tank/deep", Mountpoint: "/a/b/c/d/e"},
want: 5, // path="a/b/c/d/e" has 4 slashes, 1+4=5
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := zfsMountpointScore(tt.ds)
if got != tt.want {
t.Errorf("zfsMountpointScore(%+v) = %d, want %d", tt.ds, got, tt.want)
}
})
}
}
func TestZfsPoolFromDeviceExtended(t *testing.T) {
tests := []struct {
name string
device string
want string
}{
{
name: "whitespace only",
device: " ",
want: "",
},
{
name: "pool with leading space",
device: " tank/data",
want: "tank",
},
{
name: "pool with trailing space",
device: "tank/data ",
want: "tank",
},
{
name: "pool with hyphen",
device: "my-pool/data",
want: "my-pool",
},
{
name: "pool with underscore",
device: "my_pool/data",
want: "my_pool",
},
{
name: "deeply nested dataset",
device: "rpool/ROOT/ubuntu/home/user",
want: "rpool",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := zfsPoolFromDevice(tt.device)
if got != tt.want {
t.Errorf("zfsPoolFromDevice(%q) = %q, want %q", tt.device, got, tt.want)
}
})
}
}
func TestCalculatePercent(t *testing.T) {
tests := []struct {
name string
total uint64
used uint64
want float64
}{
{
name: "zero total returns zero",
total: 0,
used: 0,
want: 0,
},
{
name: "zero used returns zero percent",
total: 1000,
used: 0,
want: 0,
},
{
name: "half used",
total: 1000,
used: 500,
want: 50,
},
{
name: "fully used",
total: 1000,
used: 1000,
want: 100,
},
{
name: "over 100 percent allowed",
total: 1000,
used: 1500,
want: 150,
},
{
name: "small percentage",
total: 1000,
used: 1,
want: 0.1,
},
{
name: "large values",
total: 10000000000000,
used: 7500000000000,
want: 75,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calculatePercent(tt.total, tt.used)
if got != tt.want {
t.Errorf("calculatePercent(%d, %d) = %v, want %v", tt.total, tt.used, got, tt.want)
}
})
}
}
func TestClampPercent(t *testing.T) {
tests := []struct {
name string
value float64
want float64
}{
{
name: "zero",
value: 0,
want: 0,
},
{
name: "negative clamped to zero",
value: -10,
want: 0,
},
{
name: "small negative clamped to zero",
value: -0.001,
want: 0,
},
{
name: "mid range unchanged",
value: 50,
want: 50,
},
{
name: "exactly 100",
value: 100,
want: 100,
},
{
name: "over 100 clamped",
value: 150,
want: 100,
},
{
name: "slightly over 100 clamped",
value: 100.001,
want: 100,
},
{
name: "decimal value unchanged",
value: 75.5,
want: 75.5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := clampPercent(tt.value)
if got != tt.want {
t.Errorf("clampPercent(%v) = %v, want %v", tt.value, got, tt.want)
}
})
}
}
func TestBestZFSPoolDatasets(t *testing.T) {
tests := []struct {
name string
datasets []zfsDatasetUsage
want map[string]zfsDatasetUsage
}{
{
name: "empty input",
datasets: nil,
want: map[string]zfsDatasetUsage{},
},
{
name: "single dataset",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank", Total: 1000, Used: 500},
},
want: map[string]zfsDatasetUsage{
"tank": {Pool: "tank", Dataset: "tank", Total: 1000, Used: 500},
},
},
{
name: "prefer larger total",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank/small", Total: 500, Used: 250},
{Pool: "tank", Dataset: "tank", Total: 1000, Used: 500},
},
want: map[string]zfsDatasetUsage{
"tank": {Pool: "tank", Dataset: "tank", Total: 1000, Used: 500},
},
},
{
name: "skip empty pool names",
datasets: []zfsDatasetUsage{
{Pool: "", Dataset: "orphan", Total: 1000, Used: 500},
{Pool: "tank", Dataset: "tank", Total: 2000, Used: 1000},
},
want: map[string]zfsDatasetUsage{
"tank": {Pool: "tank", Dataset: "tank", Total: 2000, Used: 1000},
},
},
{
name: "multiple pools",
datasets: []zfsDatasetUsage{
{Pool: "rpool", Dataset: "rpool", Total: 100, Used: 50},
{Pool: "tank", Dataset: "tank", Total: 1000, Used: 500},
{Pool: "backup", Dataset: "backup", Total: 500, Used: 100},
},
want: map[string]zfsDatasetUsage{
"rpool": {Pool: "rpool", Dataset: "rpool", Total: 100, Used: 50},
"tank": {Pool: "tank", Dataset: "tank", Total: 1000, Used: 500},
"backup": {Pool: "backup", Dataset: "backup", Total: 500, Used: 100},
},
},
{
name: "same pool multiple datasets picks largest",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank/a", Total: 300, Used: 100},
{Pool: "tank", Dataset: "tank/b", Total: 800, Used: 400},
{Pool: "tank", Dataset: "tank/c", Total: 500, Used: 200},
},
want: map[string]zfsDatasetUsage{
"tank": {Pool: "tank", Dataset: "tank/b", Total: 800, Used: 400},
},
},
{
name: "equal totals keeps first seen",
datasets: []zfsDatasetUsage{
{Pool: "tank", Dataset: "tank/first", Total: 1000, Used: 500},
{Pool: "tank", Dataset: "tank/second", Total: 1000, Used: 600},
},
want: map[string]zfsDatasetUsage{
"tank": {Pool: "tank", Dataset: "tank/first", Total: 1000, Used: 500},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := bestZFSPoolDatasets(tt.datasets)
if len(got) != len(tt.want) {
t.Errorf("bestZFSPoolDatasets() got %d entries, want %d", len(got), len(tt.want))
}
for pool, wantDS := range tt.want {
gotDS, ok := got[pool]
if !ok {
t.Errorf("bestZFSPoolDatasets() missing pool %q", pool)
continue
}
if gotDS != wantDS {
t.Errorf("bestZFSPoolDatasets()[%q] = %+v, want %+v", pool, gotDS, wantDS)
}
}
})
}
}
// Helper functions
func containsSubstr(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
func stringSliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}