package hostmetrics import ( "context" "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 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 }