Public source validation / validate (push) Failing after 3m8s
433 lines
15 KiB
Go
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
|
|
}
|