Add unit tests for ZFS storage utility functions (hostmetrics)

65 test cases covering 8 functions:
- parseZpoolList: zpool command output parsing (15 cases)
- uniqueZFSPools: pool name deduplication (7 cases)
- bestZFSMountpoints: mountpoint selection logic (8 cases)
- zfsMountpointScore: mountpoint scoring algorithm (7 cases)
- zfsPoolFromDevice: pool name extraction (6 cases)
- calculatePercent: percentage calculation (7 cases)
- clampPercent: value clamping (8 cases)
- bestZFSPoolDatasets: dataset selection (7 cases)

First comprehensive unit test coverage for internal/hostmetrics
package ZFS utilities.
This commit is contained in:
rcourtman 2025-11-30 12:50:58 +00:00
parent ef241d0581
commit fb976acfc3

View file

@ -88,3 +88,673 @@ func TestZFSPoolFromDevice(t *testing.T) {
}
}
}
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
}