This commit is contained in:
@@ -0,0 +1,432 @@
|
||||
package hostcollect
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/itworx/pulse/internal/host"
|
||||
)
|
||||
|
||||
// copyTree copies a fixture directory into a writable temporary root so a test can
|
||||
// mutate individual /proc files between collections, which is the only way to exercise
|
||||
// delta based metrics.
|
||||
func copyTree(t *testing.T, source string) string {
|
||||
t.Helper()
|
||||
destination := t.TempDir()
|
||||
err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
relative, relErr := filepath.Rel(source, path)
|
||||
if relErr != nil {
|
||||
return relErr
|
||||
}
|
||||
target := filepath.Join(destination, relative)
|
||||
if entry.IsDir() {
|
||||
return os.MkdirAll(target, 0o755)
|
||||
}
|
||||
data, readErr := os.ReadFile(path) //nolint:gosec // fixture path is test-controlled
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
return os.WriteFile(target, data, 0o600)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("copy fixture tree: %v", err)
|
||||
}
|
||||
return destination
|
||||
}
|
||||
|
||||
func writeFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatalf("create directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeClock struct{ current time.Time }
|
||||
|
||||
func (c *fakeClock) now() time.Time { return c.current }
|
||||
|
||||
func (c *fakeClock) advance(d time.Duration) { c.current = c.current.Add(d) }
|
||||
|
||||
func newTestCollector(t *testing.T, options Options) (*Collector, *fakeClock) {
|
||||
t.Helper()
|
||||
clock := &fakeClock{current: time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)}
|
||||
if options.ProcRoot == "" {
|
||||
options.ProcRoot = filepath.Join("testdata", "proc-healthy")
|
||||
}
|
||||
if options.SysRoot == "" {
|
||||
options.SysRoot = filepath.Join("testdata", "sys-healthy")
|
||||
}
|
||||
if options.Now == nil {
|
||||
options.Now = clock.now
|
||||
}
|
||||
if options.ClockSync == nil {
|
||||
options.ClockSync = func() (host.RawTime, error) {
|
||||
return host.RawTime{Synchronized: true, OffsetSeconds: 0.0012}, nil
|
||||
}
|
||||
}
|
||||
collector, err := New(options)
|
||||
if err != nil {
|
||||
t.Fatalf("New returned error: %v", err)
|
||||
}
|
||||
return collector, clock
|
||||
}
|
||||
|
||||
func TestCPUUsageNeedsTwoSamples(t *testing.T) {
|
||||
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
||||
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
||||
|
||||
first := newWarningSet(20)
|
||||
if cpu := collector.cpuUsage(clock.now(), first); cpu.TotalPercent != nil || cpu.PerCore != nil {
|
||||
t.Fatalf("first sample must not report utilisation: %+v", cpu)
|
||||
}
|
||||
if !containsString(first.list(), WarningCPUFirstSample) {
|
||||
t.Fatalf("expected the first sample warning, got %v", first.list())
|
||||
}
|
||||
|
||||
// Second sample: 200 additional ticks of which 100 idle and 20 iowait, so 40% busy.
|
||||
writeFile(t, filepath.Join(root, "stat"), strings.Join([]string{
|
||||
"cpu 1234647 8901 234567 45679001 12365 0 6789 1234 5000 100",
|
||||
"cpu0 617323 4450 117283 22839500 6182 0 3394 617 2500 50",
|
||||
"cpu1 617324 4451 117284 22839501 6183 0 3395 617 2500 50",
|
||||
"",
|
||||
}, "\n"))
|
||||
clock.advance(10 * time.Second)
|
||||
|
||||
second := newWarningSet(20)
|
||||
cpu := collector.cpuUsage(clock.now(), second)
|
||||
if cpu.TotalPercent == nil {
|
||||
t.Fatal("second sample must report utilisation")
|
||||
}
|
||||
if got := *cpu.TotalPercent; got < 39.9 || got > 40.1 {
|
||||
t.Fatalf("total = %v, want 40", got)
|
||||
}
|
||||
if cpu.IOWaitPercent == nil || *cpu.IOWaitPercent < 9.9 || *cpu.IOWaitPercent > 10.1 {
|
||||
t.Fatalf("iowait = %v, want 10", cpu.IOWaitPercent)
|
||||
}
|
||||
if len(cpu.PerCore) != 2 {
|
||||
t.Fatalf("per core = %v", cpu.PerCore)
|
||||
}
|
||||
if warnings := second.list(); len(warnings) != 0 {
|
||||
t.Fatalf("unexpected warnings: %v", warnings)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageReportsNothingWhenCountersWrap(t *testing.T) {
|
||||
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
||||
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
||||
collector.cpuUsage(clock.now(), newWarningSet(20))
|
||||
|
||||
writeFile(t, filepath.Join(root, "stat"), "cpu 10 1 2 3 4 0 5 0 0 0\ncpu0 5 0 1 1 2 0 2 0 0 0\ncpu1 5 1 1 2 2 0 3 0 0 0\n")
|
||||
clock.advance(10 * time.Second)
|
||||
|
||||
warnings := newWarningSet(20)
|
||||
cpu := collector.cpuUsage(clock.now(), warnings)
|
||||
if cpu.TotalPercent != nil || cpu.PerCore != nil {
|
||||
t.Fatalf("a wrapped counter must not produce a percentage: %+v", cpu)
|
||||
}
|
||||
if !containsString(warnings.list(), WarningCPUCounterReset) {
|
||||
t.Fatalf("expected a counter reset warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageKeepsTotalWhenCoreCountChanges(t *testing.T) {
|
||||
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
||||
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
||||
collector.cpuUsage(clock.now(), newWarningSet(20))
|
||||
|
||||
writeFile(t, filepath.Join(root, "stat"), strings.Join([]string{
|
||||
"cpu 1234647 8901 234567 45679001 12365 0 6789 1234 5000 100",
|
||||
"cpu0 617323 4450 117283 22839500 6182 0 3394 617 2500 50",
|
||||
"cpu1 617324 4451 117284 22839501 6183 0 3395 617 2500 50",
|
||||
"cpu2 0 0 0 0 0 0 0 0 0 0",
|
||||
"",
|
||||
}, "\n"))
|
||||
clock.advance(10 * time.Second)
|
||||
|
||||
warnings := newWarningSet(20)
|
||||
cpu := collector.cpuUsage(clock.now(), warnings)
|
||||
if cpu.TotalPercent == nil {
|
||||
t.Fatal("expected the aggregate to survive a topology change")
|
||||
}
|
||||
if cpu.PerCore != nil {
|
||||
t.Fatalf("per core values are not comparable across a topology change: %v", cpu.PerCore)
|
||||
}
|
||||
if !containsString(warnings.list(), WarningCPUTopologyChanged) {
|
||||
t.Fatalf("expected a topology warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageTruncatesToCoreLimit(t *testing.T) {
|
||||
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
||||
collector, clock := newTestCollector(t, Options{ProcRoot: root, HostLimits: host.Limits{MaxCores: 1, MaxFilesystems: 4, MaxInterfaces: 4, MaxWarnings: 5}})
|
||||
collector.cpuUsage(clock.now(), newWarningSet(20))
|
||||
|
||||
writeFile(t, filepath.Join(root, "stat"), strings.Join([]string{
|
||||
"cpu 1234647 8901 234567 45679001 12365 0 6789 1234 5000 100",
|
||||
"cpu0 617323 4450 117283 22839500 6182 0 3394 617 2500 50",
|
||||
"cpu1 617324 4451 117284 22839501 6183 0 3395 617 2500 50",
|
||||
"",
|
||||
}, "\n"))
|
||||
clock.advance(10 * time.Second)
|
||||
|
||||
warnings := newWarningSet(20)
|
||||
cpu := collector.cpuUsage(clock.now(), warnings)
|
||||
if len(cpu.PerCore) != 1 {
|
||||
t.Fatalf("per core = %v, want one entry", cpu.PerCore)
|
||||
}
|
||||
if !containsString(warnings.list(), WarningCPUCoresTruncated) {
|
||||
t.Fatalf("expected a truncation warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCPUUsageDegradesWhenProcStatIsUnreadable(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
||||
warnings := newWarningSet(20)
|
||||
if cpu := collector.cpuUsage(clock.now(), warnings); cpu.TotalPercent != nil {
|
||||
t.Fatalf("unexpected utilisation: %+v", cpu)
|
||||
}
|
||||
if !containsString(warnings.list(), WarningCPUUnavailable) {
|
||||
t.Fatalf("expected an unavailable warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkSkipsLoopbackAndReadsOperationalState(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{})
|
||||
warnings := newWarningSet(20)
|
||||
interfaces := collector.network(warnings)
|
||||
if len(interfaces) != 2 {
|
||||
t.Fatalf("interfaces = %+v, want br0 and eth0", interfaces)
|
||||
}
|
||||
if interfaces[0].Name != "br0" || interfaces[1].Name != "eth0" {
|
||||
t.Fatalf("interfaces are not sorted: %+v", interfaces)
|
||||
}
|
||||
if interfaces[1].State != "up" {
|
||||
t.Fatalf("eth0 state = %q, want up", interfaces[1].State)
|
||||
}
|
||||
if interfaces[0].State != "unknown" {
|
||||
t.Fatalf("br0 state = %q", interfaces[0].State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkTruncatesToInterfaceLimit(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{HostLimits: host.Limits{MaxCores: 8, MaxFilesystems: 8, MaxInterfaces: 1, MaxWarnings: 5}})
|
||||
warnings := newWarningSet(20)
|
||||
if interfaces := collector.network(warnings); len(interfaces) != 1 {
|
||||
t.Fatalf("interfaces = %+v, want one", interfaces)
|
||||
}
|
||||
if !containsString(warnings.list(), WarningNetworkTruncated) {
|
||||
t.Fatalf("expected a truncation warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemsAreDisabledWithoutAnExplicitRoot(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{StatFS: func(string) (FilesystemUsage, error) {
|
||||
t.Fatal("statfs must not be called when no filesystem root is configured")
|
||||
return FilesystemUsage{}, nil
|
||||
}})
|
||||
warnings := newWarningSet(20)
|
||||
if filesystems := collector.filesystems(warnings); filesystems != nil {
|
||||
t.Fatalf("expected no filesystems, got %+v", filesystems)
|
||||
}
|
||||
if !containsString(warnings.list(), WarningFilesystemDisabled) {
|
||||
t.Fatalf("expected the disabled warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemsSkipPseudoMountsAndSurviveStatFailures(t *testing.T) {
|
||||
requested := []string{}
|
||||
collector, _ := newTestCollector(t, Options{
|
||||
FilesystemRoot: "/host/root",
|
||||
StatFS: func(path string) (FilesystemUsage, error) {
|
||||
path = filepath.ToSlash(path)
|
||||
requested = append(requested, path)
|
||||
if strings.HasSuffix(path, "/mnt/disk2") {
|
||||
return FilesystemUsage{}, errors.New("stale nfs handle")
|
||||
}
|
||||
if strings.HasSuffix(path, "/boot") {
|
||||
return FilesystemUsage{CapacityBytes: 0}, nil
|
||||
}
|
||||
return FilesystemUsage{CapacityBytes: 1000, UsedBytes: 400, InodesTotal: 100, InodesUsed: 40}, nil
|
||||
},
|
||||
})
|
||||
warnings := newWarningSet(20)
|
||||
filesystems := collector.filesystems(warnings)
|
||||
|
||||
mounts := make([]string, 0, len(filesystems))
|
||||
for _, item := range filesystems {
|
||||
mounts = append(mounts, item.Mount)
|
||||
}
|
||||
want := []string{"/", "/mnt/cache", "/mnt/disk1", "/mnt/disks/Media Backup", "/mnt/user"}
|
||||
if strings.Join(mounts, ",") != strings.Join(want, ",") {
|
||||
t.Fatalf("mounts = %v, want %v", mounts, want)
|
||||
}
|
||||
for _, path := range requested {
|
||||
if !strings.HasPrefix(path, "/host/root") {
|
||||
t.Fatalf("statfs called outside the configured root: %q", path)
|
||||
}
|
||||
}
|
||||
if !containsString(warnings.list(), WarningFilesystemPartial) {
|
||||
t.Fatalf("expected a partial warning, got %v", warnings.list())
|
||||
}
|
||||
if filesystems[0].Inodes == nil || filesystems[0].Inodes.Used != 40 {
|
||||
t.Fatalf("inodes missing: %+v", filesystems[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemsTruncateToLimit(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{
|
||||
FilesystemRoot: "/",
|
||||
HostLimits: host.Limits{MaxCores: 8, MaxFilesystems: 2, MaxInterfaces: 8, MaxWarnings: 5},
|
||||
StatFS: func(string) (FilesystemUsage, error) {
|
||||
return FilesystemUsage{CapacityBytes: 1000, UsedBytes: 100}, nil
|
||||
},
|
||||
})
|
||||
warnings := newWarningSet(20)
|
||||
if filesystems := collector.filesystems(warnings); len(filesystems) != 2 {
|
||||
t.Fatalf("filesystems = %d, want 2", len(filesystems))
|
||||
}
|
||||
if !containsString(warnings.list(), WarningFilesystemTruncated) {
|
||||
t.Fatalf("expected a truncation warning, got %v", warnings.list())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostSnapshotIsAcceptedByTheDomain(t *testing.T) {
|
||||
collector, clock := newTestCollector(t, Options{
|
||||
FilesystemRoot: "/",
|
||||
StatFS: func(string) (FilesystemUsage, error) {
|
||||
return FilesystemUsage{CapacityBytes: 4000, UsedBytes: 2500, InodesTotal: 200, InodesUsed: 30}, nil
|
||||
},
|
||||
})
|
||||
raw, err := collector.Host(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Host returned error: %v", err)
|
||||
}
|
||||
if raw.Identity.Name != "tower" || raw.Identity.Kernel != "6.1.79-Unraid" {
|
||||
t.Fatalf("unexpected identity: %+v", raw.Identity)
|
||||
}
|
||||
if raw.UptimeSeconds != 351282.31 {
|
||||
t.Fatalf("uptime = %v", raw.UptimeSeconds)
|
||||
}
|
||||
if raw.BootTime == nil || !raw.BootTime.Equal(clock.now().Add(-time.Duration(351282.31*float64(time.Second)))) {
|
||||
t.Fatalf("boot time = %v", raw.BootTime)
|
||||
}
|
||||
if raw.Memory.TotalBytes == 0 || raw.Memory.AvailableBytes == 0 {
|
||||
t.Fatalf("memory not collected: %+v", raw.Memory)
|
||||
}
|
||||
if raw.Load.One != 1.52 {
|
||||
t.Fatalf("load = %+v", raw.Load)
|
||||
}
|
||||
if !raw.Time.Synchronized {
|
||||
t.Fatalf("time = %+v", raw.Time)
|
||||
}
|
||||
if !containsString(raw.Warnings, WarningCPUFirstSample) {
|
||||
t.Fatalf("expected the first sample warning, got %v", raw.Warnings)
|
||||
}
|
||||
// The collector's output must pass domain normalization unchanged; that is the
|
||||
// contract the API reads through.
|
||||
if _, err := host.Normalize(raw, clock.now(), host.Limits{}, host.Policy{}); err != nil {
|
||||
t.Fatalf("host.Normalize rejected the collected snapshot: %v", err)
|
||||
}
|
||||
payload, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
t.Fatal("empty payload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostFailsWhenRequiredSourcesAreMalformed(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{
|
||||
ProcRoot: filepath.Join("testdata", "proc-messy"),
|
||||
SysRoot: filepath.Join("testdata", "sys-healthy"),
|
||||
})
|
||||
if _, err := collector.Host(context.Background()); err == nil {
|
||||
t.Fatal("expected a malformed uptime to fail the whole host collection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostDegradesLoadAndClockIntoWarnings(t *testing.T) {
|
||||
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
||||
writeFile(t, filepath.Join(root, "loadavg"), "broken\n")
|
||||
collector, clock := newTestCollector(t, Options{
|
||||
ProcRoot: root,
|
||||
ClockSync: func() (host.RawTime, error) { return host.RawTime{}, errors.New("operation not permitted") },
|
||||
})
|
||||
raw, err := collector.Host(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Host returned error: %v", err)
|
||||
}
|
||||
if !containsString(raw.Warnings, WarningLoadUnavailable) || !containsString(raw.Warnings, WarningClockProbeAssumed) {
|
||||
t.Fatalf("warnings = %v", raw.Warnings)
|
||||
}
|
||||
if !raw.Time.Synchronized {
|
||||
t.Fatal("an unverifiable clock must not be reported as a confirmed clock fault")
|
||||
}
|
||||
if _, err := host.Normalize(raw, clock.now(), host.Limits{}, host.Policy{}); err != nil {
|
||||
t.Fatalf("host.Normalize rejected the degraded snapshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostHonoursContextCancellation(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{})
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err := collector.Host(ctx); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("error = %v, want context.Canceled", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostNameOverrideWinsOverTheNamespaceHostname(t *testing.T) {
|
||||
collector, _ := newTestCollector(t, Options{
|
||||
ProcRoot: filepath.Join("testdata", "proc-messy"),
|
||||
HostName: "tower",
|
||||
})
|
||||
identity, err := collector.identity()
|
||||
if err != nil {
|
||||
t.Fatalf("identity returned error: %v", err)
|
||||
}
|
||||
if identity.Name != "tower" {
|
||||
t.Fatalf("name = %q, want the override", identity.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsUnsafeLimits(t *testing.T) {
|
||||
if _, err := New(Options{HostLimits: host.Limits{MaxCores: 9999}}); err == nil {
|
||||
t.Fatal("expected out of bounds host limits to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWarningSetIsDeduplicatedAndBounded(t *testing.T) {
|
||||
warnings := newWarningSet(2)
|
||||
warnings.add(WarningCPUUnavailable)
|
||||
warnings.add(WarningCPUUnavailable)
|
||||
warnings.add(WarningLoadUnavailable)
|
||||
warnings.add(WarningNetworkUnavailable)
|
||||
if list := warnings.list(); len(list) != 2 {
|
||||
t.Fatalf("warnings = %v, want two", list)
|
||||
}
|
||||
}
|
||||
|
||||
func containsString(values []string, want string) bool {
|
||||
for _, value := range values {
|
||||
if value == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user