mirror of
https://github.com/henrygd/beszel.git
synced 2026-09-22 01:17:48 +02:00
- track pool capacity, health, I/O, scrub status, and vdev errors - report dataset usage and correct ZFS filesystem metrics - add pool charts, detail views, refresh controls, and health alerts - persist pool details and include ZFS usage in disk alerts - support configurable detail intervals and legacy agent compatibility --------- Co-authored-by: hank <hank@henrygd.me>
246 lines
8.2 KiB
Go
246 lines
8.2 KiB
Go
//go:build testing
|
|
|
|
package agent
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/henrygd/beszel/agent/zfs"
|
|
"github.com/henrygd/beszel/internal/entities/system"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestUpdatePopulatesZfsPools(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
return []zfs.PoolStat{{Name: "tank", Size: 23999000000000, Alloc: 12000000000000, Free: 11999000000000, Health: "DEGRADED"}}, nil
|
|
}
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
return []zfs.Dataset{
|
|
{Name: "tank/apps", Used: 5000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
|
|
{Name: "tank/backup", Used: 6000000000000, Avail: 11999000000000, Mountpoint: "/tank/backup"},
|
|
// Small zvol (Proxmox VM EFI disk): must not round to zero.
|
|
{Name: "rpool/vm-100-disk-2", Used: 4194304, Avail: 0, Mountpoint: "-"},
|
|
}, nil
|
|
}
|
|
var kernelCalls int
|
|
zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
|
kernelCalls++
|
|
return []zfs.PoolKernelStat{{
|
|
Name: "tank", Health: "ONLINE",
|
|
NRead: uint64(kernelCalls-1) * 1250, NWrite: uint64(kernelCalls-1) * 5120,
|
|
}}, nil
|
|
}
|
|
|
|
var stats system.Stats
|
|
// The first kernel sample establishes the cumulative-counter baseline.
|
|
zm.Update(&stats)
|
|
zm.kernelSamples["tank"] = poolKernelSample{at: time.Now().Add(-time.Second)}
|
|
zm.Update(&stats)
|
|
require.NotNil(t, stats.ZfsPools)
|
|
require.Contains(t, stats.ZfsPools, "tank")
|
|
assert.InDelta(t, 22350.8105, stats.ZfsPools["tank"].Total, 0.0001) // Size in GiB
|
|
assert.InDelta(t, 11175.8709, stats.ZfsPools["tank"].Used, 0.0001) // Alloc in GiB
|
|
assert.Equal(t, "ONLINE", stats.ZfsPools["tank"].Health)
|
|
assert.InDelta(t, 1250, stats.ZfsPools["tank"].ReadBytes, 5)
|
|
assert.InDelta(t, 5120, stats.ZfsPools["tank"].WriteBytes, 5)
|
|
|
|
}
|
|
|
|
// TestUpdateKernelStatsMissing verifies pools without a kernel sample report zero
|
|
// I/O instead of erroring.
|
|
func TestUpdateKernelStatsMissing(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
return []zfs.PoolStat{{Name: "tank", Size: 1, Alloc: 1, Health: "ONLINE"}}, nil
|
|
}
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
|
return nil, zfs.ErrNoZfs
|
|
}
|
|
|
|
var stats system.Stats
|
|
zm.Update(&stats)
|
|
require.NotNil(t, stats.ZfsPools)
|
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
|
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
|
|
}
|
|
|
|
func TestUpdateKernelCounterReset(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
return []zfs.PoolStat{{Name: "tank", Health: "ONLINE"}}, nil
|
|
}
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
zm.kernelSamples = map[string]poolKernelSample{
|
|
"tank": {nread: 100, nwrite: 200, at: time.Now().Add(-time.Second)},
|
|
}
|
|
zm.kernelStatsFn = func() ([]zfs.PoolKernelStat, error) {
|
|
return []zfs.PoolKernelStat{{Name: "tank", Health: "ONLINE", NRead: 10, NWrite: 20}}, nil
|
|
}
|
|
|
|
var stats system.Stats
|
|
zm.Update(&stats)
|
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].ReadBytes)
|
|
assert.Equal(t, uint64(0), stats.ZfsPools["tank"].WriteBytes)
|
|
}
|
|
|
|
func TestUpdateNoZfs(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
calls := 0
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
calls++
|
|
return nil, zfs.ErrNoZfs
|
|
}
|
|
|
|
var stats system.Stats
|
|
zm.Update(&stats)
|
|
zm.Update(&stats)
|
|
assert.Nil(t, stats.ZfsPools)
|
|
assert.Equal(t, 1, calls, "failed pool discovery should be cached until the next refresh interval")
|
|
}
|
|
|
|
func TestUpdateEmptyPools(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
calls := 0
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
calls++
|
|
return nil, nil
|
|
}
|
|
|
|
var stats system.Stats
|
|
zm.Update(&stats)
|
|
zm.Update(&stats)
|
|
assert.Nil(t, stats.ZfsPools)
|
|
assert.Equal(t, 1, calls, "an empty pool inventory should be cached until the next refresh interval")
|
|
}
|
|
|
|
func TestDatasetUsage(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
calls := 0
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
calls++
|
|
return []zfs.Dataset{
|
|
{Name: "tank", Used: 12000000000000, Avail: 11999000000000, Mountpoint: "/tank"},
|
|
{Name: "tank/apps", Used: 1000000000000, Avail: 11999000000000, Mountpoint: "/tank/apps"},
|
|
{Name: "rpool", Used: 900000000000, Avail: 300000000000, Mountpoint: "-"}, // zvol/unmounted: excluded
|
|
}, nil
|
|
}
|
|
|
|
usage := zm.DatasetUsage()
|
|
require.Len(t, usage, 2)
|
|
assert.Equal(t, zfsDatasetUsage{used: 12000000000000, avail: 11999000000000}, usage["/tank"])
|
|
assert.Equal(t, zfsDatasetUsage{used: 1000000000000, avail: 11999000000000}, usage["/tank/apps"])
|
|
assert.Equal(t, 1, calls)
|
|
|
|
// Second call within the refresh window must not re-run the collector.
|
|
zm.DatasetUsage()
|
|
assert.Equal(t, 1, calls)
|
|
}
|
|
|
|
func TestDatasetUsageRefreshOnErrorKeepsPrevious(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
return []zfs.Dataset{{Name: "tank", Used: 1, Avail: 1, Mountpoint: "/tank"}}, nil
|
|
}
|
|
assert.Len(t, zm.DatasetUsage(), 1)
|
|
|
|
// Force refresh window expiry, then a failing collector.
|
|
zm.lastUsageRefresh = time.Now().Add(-10 * time.Minute)
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
return nil, zfs.ErrNoZfs
|
|
}
|
|
usage := zm.DatasetUsage()
|
|
assert.Len(t, usage, 1, "previous usage should be retained on error")
|
|
}
|
|
|
|
func TestGetDetailForceRefresh(t *testing.T) {
|
|
zm := &ZfsManager{detailInterval: time.Hour}
|
|
poolCalls := 0
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
poolCalls++
|
|
return []zfs.PoolStat{{Name: "tank", Alloc: uint64(poolCalls)}}, nil
|
|
}
|
|
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
|
|
first := zm.GetDetail(false)
|
|
assert.True(t, first.Complete)
|
|
require.Len(t, first.Pools, 1)
|
|
assert.Equal(t, uint64(1), first.Pools[0].Alloc)
|
|
|
|
cached := zm.GetDetail(false)
|
|
require.Len(t, cached.Pools, 1)
|
|
assert.Equal(t, uint64(1), cached.Pools[0].Alloc)
|
|
assert.Equal(t, 1, poolCalls)
|
|
|
|
refreshed := zm.GetDetail(true)
|
|
assert.True(t, refreshed.Complete)
|
|
require.Len(t, refreshed.Pools, 1)
|
|
assert.Equal(t, uint64(2), refreshed.Pools[0].Alloc)
|
|
assert.Equal(t, 2, poolCalls)
|
|
}
|
|
|
|
func TestGetDetailSuccessfulEmptyInventoryClearsCache(t *testing.T) {
|
|
zm := &ZfsManager{detailInterval: time.Hour}
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
return []zfs.PoolStat{{Name: "tank"}}, nil
|
|
}
|
|
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, nil }
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, nil }
|
|
|
|
require.Len(t, zm.GetDetail(false).Pools, 1)
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, nil }
|
|
empty := zm.GetDetail(true)
|
|
assert.True(t, empty.Complete)
|
|
assert.Empty(t, empty.Pools)
|
|
}
|
|
|
|
func TestGetDetailFailureReturnsIncompleteCachedInventory(t *testing.T) {
|
|
zm := &ZfsManager{detailInterval: time.Hour}
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) {
|
|
return []zfs.PoolStat{{Name: "tank"}}, nil
|
|
}
|
|
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) {
|
|
return []zfs.PoolStatus{{Name: "tank", Vdevs: []zfs.VdevStatus{{Name: "mirror-0"}}}}, nil
|
|
}
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
return []zfs.Dataset{{Name: "tank/data"}}, nil
|
|
}
|
|
first := zm.GetDetail(false)
|
|
require.True(t, first.Complete)
|
|
require.Len(t, first.Pools[0].Vdevs, 1)
|
|
require.Len(t, first.Pools[0].Datasets, 1)
|
|
|
|
zm.poolStatusesFn = func() ([]zfs.PoolStatus, error) { return nil, zfs.ErrNoZfs }
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) { return nil, zfs.ErrNoZfs }
|
|
partial := zm.GetDetail(true)
|
|
require.True(t, partial.Complete)
|
|
require.Len(t, partial.Pools[0].Vdevs, 1)
|
|
require.Len(t, partial.Pools[0].Datasets, 1)
|
|
|
|
zm.poolStatsFn = func() ([]zfs.PoolStat, error) { return nil, zfs.ErrNoZfs }
|
|
lastSuccessfulRefresh := zm.lastDetailRefresh
|
|
failed := zm.GetDetail(true)
|
|
assert.False(t, failed.Complete)
|
|
require.Len(t, failed.Pools, 1)
|
|
assert.Equal(t, "tank", failed.Pools[0].Name)
|
|
assert.Equal(t, lastSuccessfulRefresh, zm.lastDetailRefresh)
|
|
}
|
|
|
|
func TestZfsMountpoints(t *testing.T) {
|
|
zm := &ZfsManager{}
|
|
zm.datasetsFn = func() ([]zfs.Dataset, error) {
|
|
return []zfs.Dataset{
|
|
{Name: "tank", Mountpoint: "/tank"},
|
|
{Name: "rpool/ROOT/pve-1", Mountpoint: "/"},
|
|
}, nil
|
|
}
|
|
mountpoints := zm.ZfsMountpoints()
|
|
assert.Len(t, mountpoints, 2)
|
|
assert.True(t, mountpoints["/tank"])
|
|
assert.True(t, mountpoints["/"])
|
|
}
|