Public source validation / validate (push) Failing after 3m8s
262 lines
9.1 KiB
Go
262 lines
9.1 KiB
Go
package hostcollect
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/itworx/pulse/internal/process"
|
|
)
|
|
|
|
func processByPID(t *testing.T, snapshot process.RawSnapshot, pid int) process.RawProcess {
|
|
t.Helper()
|
|
for _, item := range snapshot.Processes {
|
|
if item.PID == pid {
|
|
return item
|
|
}
|
|
}
|
|
t.Fatalf("pid %d not found in %+v", pid, snapshot.Processes)
|
|
return process.RawProcess{}
|
|
}
|
|
|
|
func TestParseProcessStatHandlesParenthesesInTheProgramName(t *testing.T) {
|
|
stat, err := parseProcessStat(readFixture(t, "proc-healthy", "1234", "stat"), 1234)
|
|
if err != nil {
|
|
t.Fatalf("parseProcessStat returned error: %v", err)
|
|
}
|
|
if stat.comm != "my app) (2)" {
|
|
t.Fatalf("comm = %q; the parser must cut at the last closing parenthesis", stat.comm)
|
|
}
|
|
if stat.ticks != 100000+20000 {
|
|
t.Fatalf("ticks = %d", stat.ticks)
|
|
}
|
|
if stat.startTime != 5000 || stat.rssPages != 250000 {
|
|
t.Fatalf("unexpected stat: %+v", stat)
|
|
}
|
|
if stat.state != "sleeping" {
|
|
t.Fatalf("state = %q", stat.state)
|
|
}
|
|
}
|
|
|
|
func TestParseProcessStatRejectsMalformedInput(t *testing.T) {
|
|
cases := map[string][]byte{
|
|
"truncated": readFixture(t, "proc-healthy", "9999", "stat"),
|
|
"no comm": []byte("1 systemd S 0 1\n"),
|
|
"empty": {},
|
|
"bad number": []byte("1 (x) S 0 0 0 0 0 0 0 0 0 0 nan nan 0 0 0 0 0 0 0 0 0 0\n"),
|
|
}
|
|
for name, data := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if _, err := parseProcessStat(data, 1); err == nil {
|
|
t.Fatal("expected an error")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessesCollectsInventoryAndSkipsUnreadableTasks(t *testing.T) {
|
|
collector, clock := newTestCollector(t, Options{PageSize: 4096})
|
|
snapshot, err := collector.Processes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Processes returned error: %v", err)
|
|
}
|
|
if len(snapshot.Processes) != 5 {
|
|
t.Fatalf("processes = %d, want 5 (1, 2, 1234, 4567, 5555): %+v", len(snapshot.Processes), snapshot.Processes)
|
|
}
|
|
// 9999 has a truncated stat file; 3131 exists in the directory listing and even has
|
|
// a cmdline, but its stat is already gone — the task exited between readdir and
|
|
// open, which must cost one row and not the whole collection.
|
|
for _, pid := range []int{9999, 3131} {
|
|
for _, item := range snapshot.Processes {
|
|
if item.PID == pid {
|
|
t.Fatalf("pid %d must be skipped: malformed stat or a task that vanished mid-scan", pid)
|
|
}
|
|
}
|
|
}
|
|
|
|
init := processByPID(t, snapshot, 1)
|
|
if init.Name != "init" {
|
|
t.Fatalf("name = %q, want the command line's program name", init.Name)
|
|
}
|
|
if init.MemoryBytes != 12844*1024 {
|
|
t.Fatalf("memory = %d, want VmRSS from status", init.MemoryBytes)
|
|
}
|
|
if init.RuntimeSeconds < 351281 || init.RuntimeSeconds > 351283 {
|
|
t.Fatalf("runtime = %v", init.RuntimeSeconds)
|
|
}
|
|
|
|
kernelThread := processByPID(t, snapshot, 2)
|
|
if kernelThread.Name != "kthreadd" {
|
|
t.Fatalf("a kernel thread with an empty cmdline must fall back to comm, got %q", kernelThread.Name)
|
|
}
|
|
if kernelThread.MemoryBytes != 0 {
|
|
t.Fatalf("memory = %d, want 0 for a kernel thread", kernelThread.MemoryBytes)
|
|
}
|
|
|
|
// 5555 has a stat file but no status or cmdline: it exited between the two reads.
|
|
vanished := processByPID(t, snapshot, 5555)
|
|
if vanished.Name != "short-lived" || vanished.MemoryBytes != 500*4096 {
|
|
t.Fatalf("unexpected fallback for a task that vanished between reads: %+v", vanished)
|
|
}
|
|
if vanished.State != "zombie" {
|
|
t.Fatalf("state = %q", vanished.State)
|
|
}
|
|
|
|
if _, err := process.Normalize(snapshot, clock.now(), process.Limits{}); err != nil {
|
|
t.Fatalf("process.Normalize rejected the collected snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessesNeverCopyCommandLineArguments(t *testing.T) {
|
|
collector, _ := newTestCollector(t, Options{})
|
|
snapshot, err := collector.Processes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Processes returned error: %v", err)
|
|
}
|
|
payload, err := json.Marshal(snapshot)
|
|
if err != nil {
|
|
t.Fatalf("marshal: %v", err)
|
|
}
|
|
for _, secret := range []string{"SUPER-SECRET-VALUE", "hunter2", "--token", "--password", "virtio-net-pci"} {
|
|
if strings.Contains(string(payload), secret) {
|
|
t.Fatalf("snapshot leaked command line argument %q", secret)
|
|
}
|
|
}
|
|
weird := processByPID(t, snapshot, 1234)
|
|
if weird.Name != "weird app (2)" {
|
|
t.Fatalf("name = %q, want only the program name", weird.Name)
|
|
}
|
|
// The 29 KiB command line of pid 4567 is read bounded and reduced to its base name.
|
|
huge := processByPID(t, snapshot, 4567)
|
|
if huge.Name != "qemu-system-x86_64" {
|
|
t.Fatalf("name = %q", huge.Name)
|
|
}
|
|
if len(huge.Name) > maxNameBytes {
|
|
t.Fatalf("name length = %d", len(huge.Name))
|
|
}
|
|
}
|
|
|
|
func TestProcessesEnforceTheRowLimitByCost(t *testing.T) {
|
|
collector, _ := newTestCollector(t, Options{ProcessLimits: process.Limits{MaxRows: 2, MaxPageSize: 10}})
|
|
snapshot, err := collector.Processes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("Processes returned error: %v", err)
|
|
}
|
|
if len(snapshot.Processes) != 2 {
|
|
t.Fatalf("processes = %d, want the limit of 2", len(snapshot.Processes))
|
|
}
|
|
// Without a previous sample every CPU percentage is zero, so the tie breaks on
|
|
// resident pages: the two largest tasks survive.
|
|
if snapshot.Processes[0].PID != 1234 || snapshot.Processes[1].PID != 4567 {
|
|
t.Fatalf("unexpected survivors: %+v", snapshot.Processes)
|
|
}
|
|
}
|
|
|
|
func TestProcessCPUPercentIsADeltaAndSurvivesPIDReuse(t *testing.T) {
|
|
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
|
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
|
|
|
first, err := collector.Processes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("first scan: %v", err)
|
|
}
|
|
if processByPID(t, first, 1234).CPUPercent != 0 {
|
|
t.Fatal("a single sample cannot yield CPU utilisation")
|
|
}
|
|
|
|
// 500 extra ticks over 10 seconds at 100 USER_HZ is exactly 50% of one core.
|
|
writeFile(t, filepath.Join(root, "1234", "stat"),
|
|
"1234 (my app) (2)) S 1 1234 1234 0 -1 4194304 55555 0 12 0 100400 20100 0 0 20 0 8 0 5000 987654321 250000 0 1 1 0 0 0 0 0 0 0 0 0 0 17 5 0 0 0 0 0\n")
|
|
// pid 1 is replaced by a new task that reuses the PID: a different start time.
|
|
writeFile(t, filepath.Join(root, "1", "stat"),
|
|
"1 (systemd) S 0 1 1 0 -1 4194560 12345 678910 12 34 90456 789 1000 2000 20 0 1 0 99 172032000 3210 0 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0\n")
|
|
clock.advance(10 * time.Second)
|
|
|
|
second, err := collector.Processes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second scan: %v", err)
|
|
}
|
|
if got := processByPID(t, second, 1234).CPUPercent; got < 49.9 || got > 50.1 {
|
|
t.Fatalf("cpu = %v, want 50", got)
|
|
}
|
|
if got := processByPID(t, second, 1).CPUPercent; got != 0 {
|
|
t.Fatalf("cpu = %v; a reused PID must not inherit the previous task's ticks", got)
|
|
}
|
|
if _, err := process.Normalize(second, clock.now(), process.Limits{}); err != nil {
|
|
t.Fatalf("process.Normalize rejected the snapshot: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProcessCPUPercentIgnoresBackwardsCounters(t *testing.T) {
|
|
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
|
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
|
if _, err := collector.Processes(context.Background()); err != nil {
|
|
t.Fatalf("first scan: %v", err)
|
|
}
|
|
writeFile(t, filepath.Join(root, "1234", "stat"),
|
|
"1234 (my app) (2)) S 1 1234 1234 0 -1 4194304 55555 0 12 0 1 1 0 0 20 0 8 0 5000 987654321 250000 0 1 1 0 0 0 0 0 0 0 0 0 0 17 5 0 0 0 0 0\n")
|
|
clock.advance(10 * time.Second)
|
|
|
|
second, err := collector.Processes(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("second scan: %v", err)
|
|
}
|
|
if got := processByPID(t, second, 1234).CPUPercent; got != 0 {
|
|
t.Fatalf("cpu = %v, want 0 for a counter that went backwards", got)
|
|
}
|
|
}
|
|
|
|
func TestProcessesRetainedStateStaysBounded(t *testing.T) {
|
|
root := copyTree(t, filepath.Join("testdata", "proc-healthy"))
|
|
collector, clock := newTestCollector(t, Options{ProcRoot: root})
|
|
if _, err := collector.Processes(context.Background()); err != nil {
|
|
t.Fatalf("first scan: %v", err)
|
|
}
|
|
if err := os.RemoveAll(filepath.Join(root, "1234")); err != nil {
|
|
t.Fatalf("remove: %v", err)
|
|
}
|
|
clock.advance(10 * time.Second)
|
|
if _, err := collector.Processes(context.Background()); err != nil {
|
|
t.Fatalf("second scan: %v", err)
|
|
}
|
|
collector.mu.Lock()
|
|
retained := len(collector.processes)
|
|
collector.mu.Unlock()
|
|
if retained != 4 {
|
|
t.Fatalf("retained samples = %d, want only the live tasks", retained)
|
|
}
|
|
}
|
|
|
|
func TestProcessesFailWhenTheProcfsRootIsUnusable(t *testing.T) {
|
|
collector, _ := newTestCollector(t, Options{ProcRoot: filepath.Join("testdata", "does-not-exist")})
|
|
if _, err := collector.Processes(context.Background()); err == nil {
|
|
t.Fatal("expected an error when the procfs root cannot be read")
|
|
}
|
|
}
|
|
|
|
func TestProcessesHonourContextCancellation(t *testing.T) {
|
|
collector, _ := newTestCollector(t, Options{})
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
if _, err := collector.Processes(ctx); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestSanitizeNameDropsControlCharacters(t *testing.T) {
|
|
if got := sanitizeName("bad\x00name\x1b[31m"); got != "badname[31m" {
|
|
t.Fatalf("sanitizeName = %q", got)
|
|
}
|
|
if got := commandName([]byte("/usr/bin/env\x00FOO=bar\x00")); got != "env" {
|
|
t.Fatalf("commandName = %q", got)
|
|
}
|
|
if got := commandName(nil); got != "" {
|
|
t.Fatalf("commandName = %q", got)
|
|
}
|
|
}
|