Files
ITWorx-Pulse-Public/internal/hostcollect/collector_test.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

433 lines
15 KiB
Go

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
}