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 }