💥 feat(agent)!: 添加主机监控代理首版

* 实现周期采集、快照缓存、本地 HTTP API 和主动上报

* 补齐 Linux 和 Windows 主机、网络、资源、进程和 TCP 采集

* 添加服务生命周期管理、配置加载、构建脚本和测试覆盖

BREAKING CHANGE: network.ips 拆分为 network.ipv4s 和 network.ipv6s。
This commit is contained in:
codex
2026-05-18 15:25:51 +00:00
commit 89d5e27874
33 changed files with 4205 additions and 0 deletions

View File

@@ -0,0 +1,169 @@
//go:build linux
package collector
import (
"context"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"mfd-monitor-agent/internal/status"
)
// TestParseAddressIPv4 verifies Linux IPv4 socket addresses are decoded.
func TestParseAddressIPv4(t *testing.T) {
ip, port, err := parseAddress("0100007F:1F90", "ipv4")
if err != nil {
t.Fatal(err)
}
if ip != "127.0.0.1" || port != 8080 {
t.Fatalf("address = %s:%d", ip, port)
}
}
// TestParseAddressIPv6 verifies Linux IPv6 socket addresses are decoded.
func TestParseAddressIPv6(t *testing.T) {
ip, port, err := parseAddress("00000000000000000000000001000000:1F90", "ipv6")
if err != nil {
t.Fatal(err)
}
if ip != "::1" || port != 8080 {
t.Fatalf("address = %s:%d", ip, port)
}
}
// TestParseProcessStatStartTime verifies field 22 parsing from /proc/<pid>/stat.
func TestParseProcessStatStartTime(t *testing.T) {
raw := "1234 (agent worker) S 1 2 3 4 5 0 0 0 0 0 11 22 0 0 20 0 1 0 987654 0 0"
startTime, err := parseProcessStatStartTime(raw)
if err != nil {
t.Fatal(err)
}
if startTime != 987654 {
t.Fatalf("start time = %d", startTime)
}
}
// TestCPUUsageFromProcStatSamples verifies CPU usage from two /proc/stat samples.
func TestCPUUsageFromProcStatSamples(t *testing.T) {
prevIdle, prevTotal, err := parseCPUTimes("cpu 100 0 100 800 0 0 0 0 0 0\n")
if err != nil {
t.Fatal(err)
}
nextIdle, nextTotal, err := parseCPUTimes("cpu 150 0 150 900 0 0 0 0 0 0\n")
if err != nil {
t.Fatal(err)
}
got := cpuUsagePercent(prevIdle, prevTotal, nextIdle, nextTotal)
if got != 50 {
t.Fatalf("usage percent = %f", got)
}
}
// TestReadProcessStatReturnsStartTime verifies process start time is exposed.
func TestReadProcessStatReturnsStartTime(t *testing.T) {
path := filepath.Join(t.TempDir(), "stat")
raw := "1234 (agent worker) S 1 2 3 4 5 0 0 0 0 0 11 22 0 0 20 0 1 0 987654 0 0"
if err := os.WriteFile(path, []byte(raw), 0o600); err != nil {
t.Fatal(err)
}
got, err := readProcessStat(path)
if err != nil {
t.Fatal(err)
}
if got.StartTime != 987654 {
t.Fatalf("start time = %d", got.StartTime)
}
}
// TestReadMemoryReturnsBytesWithUnit verifies memory capacity fields are exposed in bytes.
func TestReadMemoryReturnsBytesWithUnit(t *testing.T) {
meminfo, err := os.ReadFile("/proc/meminfo")
if err != nil {
t.Fatal(err)
}
var totalKiB uint64
for _, line := range strings.Split(string(meminfo), "\n") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[0] == "MemTotal:" {
totalKiB, err = strconv.ParseUint(fields[1], 10, 64)
if err != nil {
t.Fatal(err)
}
break
}
}
if totalKiB == 0 {
t.Fatal("MemTotal not found")
}
got := readMemory()
if got.Total != totalKiB*1024 {
t.Fatalf("total memory = %d bytes, want %d bytes", got.Total, totalKiB*1024)
}
if got.Unit != "byte" {
t.Fatalf("unit = %q, want byte", got.Unit)
}
}
// TestShouldIncludeDiskMount keeps physical and network disks while dropping pseudo mounts.
func TestShouldIncludeDiskMount(t *testing.T) {
tests := []struct {
name string
device string
fsType string
total uint64
want bool
}{
{name: "physical block device", device: "/dev/mapper/pve-vm--103--disk--1", fsType: "ext4", total: 1024, want: true},
{name: "nfs mount", device: "server:/export/data", fsType: "nfs4", total: 1024, want: true},
{name: "lxcfs pseudo mount", device: "lxcfs", fsType: "fuse.lxcfs", total: 0, want: false},
{name: "proc pseudo mount", device: "proc", fsType: "proc", total: 0, want: false},
{name: "tmpfs memory mount", device: "tmpfs", fsType: "tmpfs", total: 1024, want: false},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := shouldIncludeDiskMount(test.device, test.fsType, test.total)
if got != test.want {
t.Fatalf("include = %t, want %t", got, test.want)
}
})
}
}
// TestCollectSocketsMarksPartialWhenProcessFDsUnreadable verifies socket mapping degradation.
func TestCollectSocketsMarksPartialWhenProcessFDsUnreadable(t *testing.T) {
oldSocketInodes := socketInodesFunc
oldReadTCPListeners := readTCPListenersFunc
t.Cleanup(func() {
socketInodesFunc = oldSocketInodes
readTCPListenersFunc = oldReadTCPListeners
})
socketInodesFunc = func() (map[string]status.ProcessInfo, error) {
return map[string]status.ProcessInfo{}, fmt.Errorf("1 process fd directory could not be read")
}
readTCPListenersFunc = func(path string, family string, inodes map[string]status.ProcessInfo) ([]status.TCPListenerInfo, error) {
return []status.TCPListenerInfo{{IP: "127.0.0.1", Port: 8080, Family: family}}, nil
}
var got status.HostStatus
err := collectSockets(context.Background(), &got)
if err == nil {
t.Fatal("expected partial mapping error")
}
if got.TCPListeners.Status != "partial" {
t.Fatalf("status = %q", got.TCPListeners.Status)
}
if len(got.TCPListeners.Items) != 2 {
t.Fatalf("listener count = %d", len(got.TCPListeners.Items))
}
}