Files
worker/internal/webapp/inventory_test.go
Gleb Tv 2c7a0236da feat: publish standalone worker
Separate worker packaging and service lifecycle from the control plane.
2026-07-13 17:55:14 +03:00

160 строки
5.0 KiB
Go

package webapp
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// makeFakeProc builds a minimal /proc tree suitable for
// ScanProcApps. It writes:
//
// - a "comm" file for each pid
// - a "cmdline" file (NUL-separated)
// - a few sockets under fd/ so the listener scan can match
//
// We deliberately skip the cwd symlink (root-only) and accept that
// the readlink call returns an error; ScanProcApps must tolerate
// EACCES / ENOENT for permission-denied fds.
func makeFakeProc(t *testing.T, pids []fakeProcEntry) string {
t.Helper()
root := t.TempDir()
for _, e := range pids {
pdir := filepath.Join(root, strconv.Itoa(e.pid))
require.NoError(t, os.MkdirAll(pdir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(pdir, "comm"), []byte(e.comm+"\n"), 0o644))
if e.cmdline != "" {
require.NoError(t, os.WriteFile(filepath.Join(pdir, "cmdline"), []byte(e.cmdline), 0o644))
}
if len(e.sockets) > 0 {
fdDir := filepath.Join(pdir, "fd")
require.NoError(t, os.MkdirAll(fdDir, 0o755))
for i, sock := range e.sockets {
// fake inode numbers are arbitrary, but must match the
// /proc/net/tcp "inode" column for ScanProcApps to
// resolve them. Use a stable mapping.
target := "socket:[" + strconv.FormatInt(sock, 10) + "]"
require.NoError(t, os.Symlink(target, filepath.Join(fdDir, strconv.Itoa(i))))
}
}
}
// /proc/net/tcp with state 0A (LISTEN) entries pointing at the
// fake inodes. We do this last so test setup is sequential.
require.NoError(t, os.MkdirAll(filepath.Join(root, "net"), 0o755))
var lines []string
lines = append(lines, " sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode ")
for _, e := range pids {
for _, ino := range e.sockets {
// "0100007F:1E61" -> 127.0.0.1:7777 in little-endian hex.
lines = append(lines, fakeTCPLine(ino, "0100007F:1E61"))
}
}
require.NoError(t, os.WriteFile(
filepath.Join(root, "net", "tcp"),
[]byte(joinLines(lines)),
0o644))
return root
}
type fakeProcEntry struct {
pid int
comm string
cmdline string
sockets []int64 // fake inode numbers
}
func fakeTCPLine(inode int64, local string) string {
// 4 hex chars for tx/rx queue (always 0), 8 hex for tr/tm->when,
// 8 hex for retrnsmt, 1 hex for uid (0), 1 hex for timeout (0),
// then inode (10 hex zero-padded). The trailing fields are zero-
// filled so the scanner skips them.
inodeHex := strconv.FormatInt(inode, 16)
for len(inodeHex) < 8 {
inodeHex = "0" + inodeHex
}
return " 0: " + local + " 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 " + inodeHex + " 0 0 0 0 0"
}
func joinLines(ls []string) string {
out := ""
for i, l := range ls {
if i > 0 {
out += "\n"
}
out += l
}
return out
}
func TestScanProcAppsEmpty(t *testing.T) {
root := t.TempDir()
apps, err := ScanProcApps(root)
require.NoError(t, err)
assert.Empty(t, apps)
}
func TestScanProcAppsSingleProcess(t *testing.T) {
root := makeFakeProc(t, []fakeProcEntry{
{pid: 42, comm: "rsmon-worker", cmdline: "rsmon-worker --foo\x00bar", sockets: []int64{1001}},
})
apps, err := ScanProcApps(root)
require.NoError(t, err)
require.Len(t, apps, 1)
assert.Equal(t, "rsmon-worker", apps[0].Name)
assert.Equal(t, 42, apps[0].PID)
assert.Equal(t, "rsmon-worker --foo bar", apps[0].Cmdline)
// ports slice should have the resolved address.
assert.Contains(t, apps[0].Ports, "127.0.0.1:7777/tcp")
}
func TestScanProcAppsSkipsKernelThreads(t *testing.T) {
// A pid directory with no comm file is treated as a vanished
// process; ScanProcApps must skip it rather than panic.
root := t.TempDir()
require.NoError(t, os.MkdirAll(filepath.Join(root, "1"), 0o755))
apps, err := ScanProcApps(root)
require.NoError(t, err)
assert.Empty(t, apps)
}
func TestInventoryStoreReplace(t *testing.T) {
dir := t.TempDir()
store, err := OpenStore(filepath.Join(dir, "webapp.db"))
require.NoError(t, err)
defer store.Close() //nolint:errcheck
inv := NewInventory(store, nil)
_ = inv // currently no public method to inject scanned rows;
// we exercise ReplaceApps directly via the store.
now := mustParseTime(t)
rows := []App{
{Name: "rsmon-worker", Source: "process", PID: 1, Ports: "7401/tcp", LastSeen: now},
{Name: "postgres", Source: "process", PID: 2, Ports: "5432/tcp", LastSeen: now},
}
ctx := context.Background()
require.NoError(t, store.ReplaceApps(ctx, rows))
got, err := store.ListApps(ctx)
require.NoError(t, err)
assert.Len(t, got, 2)
assert.Equal(t, "rsmon-worker", got[0].Name)
require.NoError(t, store.ReplaceApps(ctx, []App{
{Name: "redis", Source: "process", PID: 3, Ports: "6379/tcp", LastSeen: now},
}))
got, err = store.ListApps(ctx)
require.NoError(t, err)
assert.Len(t, got, 1)
assert.Equal(t, "redis", got[0].Name)
}
func mustParseTime(t *testing.T) time.Time {
t.Helper()
return time.Now().UTC()
}