mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-20 10:52:08 +00:00
a9dad6a29c
The unified agent gains an availability module: probe assignments arrive through the signed remote-config channel (missing key clears the schedule), each enabled target runs on its own clamped interval through the shared probe core, and results queue in a bounded drop-oldest buffer. A result is offered to the primary server until one delivery succeeds and never again after - buffered offline reports are stripped of availability results so the disk buffer cannot replay observations the queue still holds. ApplyHostReport feeds accepted reports into the probe ingestion path, where the ownership check and failure accounting live, and the probe agent id is projected onto unified availability resources for source attribution in the UI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
564 lines
15 KiB
Go
564 lines
15 KiB
Go
package host
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLegacyReportJSONDefaultsOperationReceiptProtocolUnsupported(t *testing.T) {
|
|
var report Report
|
|
if err := json.Unmarshal([]byte(`{"agent":{"id":"legacy","commandsEnabled":true},"host":{"hostname":"legacy"}}`), &report); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if report.Agent.OperationReceiptVersion != 0 {
|
|
t.Fatalf("version=%d", report.Agent.OperationReceiptVersion)
|
|
}
|
|
}
|
|
|
|
func TestReportSequenceIDRoundTrip(t *testing.T) {
|
|
value := FormatReportSequenceID("0123456789abcdef", 42)
|
|
if value != "v1:0123456789abcdef:42" {
|
|
t.Fatalf("FormatReportSequenceID() = %q", value)
|
|
}
|
|
streamID, sequence, ok := ParseReportSequenceID(value)
|
|
if !ok || streamID != "0123456789abcdef" || sequence != 42 {
|
|
t.Fatalf("ParseReportSequenceID() = (%q, %d, %v)", streamID, sequence, ok)
|
|
}
|
|
|
|
for _, invalid := range []string{"", "legacy", "v2:stream:1", "v1::1", "v1:stream:0", "v1:stream:not-a-number"} {
|
|
if _, _, ok := ParseReportSequenceID(invalid); ok {
|
|
t.Fatalf("ParseReportSequenceID(%q) unexpectedly succeeded", invalid)
|
|
}
|
|
}
|
|
}
|
|
|
|
func float64Ptr(value float64) *float64 {
|
|
return &value
|
|
}
|
|
|
|
func int64Ptr(value int64) *int64 {
|
|
return &value
|
|
}
|
|
|
|
func TestReport_JSONMarshal(t *testing.T) {
|
|
report := Report{
|
|
Agent: AgentInfo{
|
|
ID: "agent-1",
|
|
Version: "1.0.0",
|
|
Type: "unified",
|
|
IntervalSeconds: 30,
|
|
Hostname: "myhost",
|
|
},
|
|
Host: HostInfo{
|
|
ID: "host-1",
|
|
Hostname: "myhost",
|
|
DisplayName: "My Host",
|
|
Platform: "linux",
|
|
CPUCount: 4,
|
|
UptimeSeconds: 86400,
|
|
LoadAverage: []float64{0.5, 0.7, 0.9},
|
|
},
|
|
Metrics: Metrics{
|
|
CPUUsagePercent: 25.5,
|
|
Memory: MemoryMetric{
|
|
TotalBytes: 16000000000,
|
|
UsedBytes: 8000000000,
|
|
FreeBytes: 8000000000,
|
|
Usage: 50.0,
|
|
},
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
data, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal Report: %v", err)
|
|
}
|
|
|
|
var decoded Report
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("Failed to unmarshal Report: %v", err)
|
|
}
|
|
|
|
if decoded.Agent.ID != report.Agent.ID {
|
|
t.Errorf("Agent.ID = %q, want %q", decoded.Agent.ID, report.Agent.ID)
|
|
}
|
|
if decoded.Host.Hostname != report.Host.Hostname {
|
|
t.Errorf("Host.Hostname = %q, want %q", decoded.Host.Hostname, report.Host.Hostname)
|
|
}
|
|
if decoded.Metrics.CPUUsagePercent != report.Metrics.CPUUsagePercent {
|
|
t.Errorf("Metrics.CPUUsagePercent = %f, want %f", decoded.Metrics.CPUUsagePercent, report.Metrics.CPUUsagePercent)
|
|
}
|
|
}
|
|
|
|
func TestAgentInfo_Fields(t *testing.T) {
|
|
agent := AgentInfo{
|
|
ID: "agent-123",
|
|
Version: "2.0.0",
|
|
Type: "host",
|
|
IntervalSeconds: 60,
|
|
Hostname: "server1",
|
|
}
|
|
|
|
if agent.ID != "agent-123" {
|
|
t.Errorf("ID = %q, want agent-123", agent.ID)
|
|
}
|
|
if agent.Version != "2.0.0" {
|
|
t.Errorf("Version = %q, want 2.0.0", agent.Version)
|
|
}
|
|
if agent.Type != "host" {
|
|
t.Errorf("Type = %q, want host", agent.Type)
|
|
}
|
|
if agent.IntervalSeconds != 60 {
|
|
t.Errorf("IntervalSeconds = %d, want 60", agent.IntervalSeconds)
|
|
}
|
|
if agent.Hostname != "server1" {
|
|
t.Errorf("Hostname = %q, want server1", agent.Hostname)
|
|
}
|
|
}
|
|
|
|
func TestHostInfo_Fields(t *testing.T) {
|
|
host := HostInfo{
|
|
ID: "host-456",
|
|
Hostname: "myserver",
|
|
DisplayName: "My Server",
|
|
MachineID: "abc123",
|
|
Platform: "linux",
|
|
OSName: "Ubuntu",
|
|
OSVersion: "22.04",
|
|
KernelVersion: "5.15.0",
|
|
Architecture: "x86_64",
|
|
CPUModel: "Intel Core i7",
|
|
CPUCount: 8,
|
|
UptimeSeconds: 172800,
|
|
LoadAverage: []float64{1.0, 0.8, 0.6},
|
|
}
|
|
|
|
if host.ID != "host-456" {
|
|
t.Errorf("ID = %q, want host-456", host.ID)
|
|
}
|
|
if host.Platform != "linux" {
|
|
t.Errorf("Platform = %q, want linux", host.Platform)
|
|
}
|
|
if host.CPUCount != 8 {
|
|
t.Errorf("CPUCount = %d, want 8", host.CPUCount)
|
|
}
|
|
if len(host.LoadAverage) != 3 {
|
|
t.Errorf("LoadAverage length = %d, want 3", len(host.LoadAverage))
|
|
}
|
|
}
|
|
|
|
func TestMemoryMetric_Fields(t *testing.T) {
|
|
mem := MemoryMetric{
|
|
TotalBytes: 32000000000,
|
|
UsedBytes: 16000000000,
|
|
FreeBytes: 16000000000,
|
|
Usage: 50.0,
|
|
SwapTotal: 8000000000,
|
|
SwapUsed: 2000000000,
|
|
}
|
|
|
|
if mem.TotalBytes != 32000000000 {
|
|
t.Errorf("TotalBytes = %d, want 32000000000", mem.TotalBytes)
|
|
}
|
|
if mem.Usage != 50.0 {
|
|
t.Errorf("Usage = %f, want 50.0", mem.Usage)
|
|
}
|
|
if mem.SwapTotal != 8000000000 {
|
|
t.Errorf("SwapTotal = %d, want 8000000000", mem.SwapTotal)
|
|
}
|
|
}
|
|
|
|
func TestDisk_Fields(t *testing.T) {
|
|
disk := Disk{
|
|
Device: "/dev/sda1",
|
|
Mountpoint: "/",
|
|
Filesystem: "ext4",
|
|
Type: "ext4",
|
|
TotalBytes: 500000000000,
|
|
UsedBytes: 250000000000,
|
|
FreeBytes: 250000000000,
|
|
Usage: 50.0,
|
|
}
|
|
|
|
if disk.Device != "/dev/sda1" {
|
|
t.Errorf("Device = %q, want /dev/sda1", disk.Device)
|
|
}
|
|
if disk.Mountpoint != "/" {
|
|
t.Errorf("Mountpoint = %q, want /", disk.Mountpoint)
|
|
}
|
|
if disk.Usage != 50.0 {
|
|
t.Errorf("Usage = %f, want 50.0", disk.Usage)
|
|
}
|
|
}
|
|
|
|
func TestNetworkInterface_Fields(t *testing.T) {
|
|
speed := int64(1000)
|
|
net := NetworkInterface{
|
|
Name: "eth0",
|
|
MAC: "00:11:22:33:44:55",
|
|
Addresses: []string{"192.168.1.100", "fe80::1"},
|
|
RXBytes: 1000000,
|
|
TXBytes: 500000,
|
|
SpeedMbps: &speed,
|
|
}
|
|
|
|
if net.Name != "eth0" {
|
|
t.Errorf("Name = %q, want eth0", net.Name)
|
|
}
|
|
if net.MAC != "00:11:22:33:44:55" {
|
|
t.Errorf("MAC = %q, want 00:11:22:33:44:55", net.MAC)
|
|
}
|
|
if len(net.Addresses) != 2 {
|
|
t.Errorf("Addresses length = %d, want 2", len(net.Addresses))
|
|
}
|
|
if *net.SpeedMbps != 1000 {
|
|
t.Errorf("SpeedMbps = %d, want 1000", *net.SpeedMbps)
|
|
}
|
|
}
|
|
|
|
func TestSensors_Fields(t *testing.T) {
|
|
thermalWarningLevel := 0
|
|
sensors := Sensors{
|
|
TemperatureCelsius: map[string]float64{
|
|
"cpu_package": 45.0,
|
|
"cpu_core_0": 43.0,
|
|
"cpu_core_1": 44.0,
|
|
},
|
|
FanRPM: map[string]float64{
|
|
"cpu_fan": 1200.0,
|
|
},
|
|
Additional: map[string]float64{
|
|
"voltage": 12.0,
|
|
},
|
|
GPU: []GPUSensor{
|
|
{
|
|
ID: "0",
|
|
Name: "NVIDIA RTX A6000",
|
|
UtilizationPercent: float64Ptr(42),
|
|
MemoryUsedBytes: int64Ptr(8 * 1024 * 1024 * 1024),
|
|
MemoryTotalBytes: int64Ptr(48 * 1024 * 1024 * 1024),
|
|
},
|
|
},
|
|
ThermalState: &ThermalState{
|
|
Source: "pmset",
|
|
Pressure: "nominal",
|
|
ThermalWarningLevel: &thermalWarningLevel,
|
|
LimitsPercent: map[string]int{"cpu_speed_limit": 100},
|
|
},
|
|
}
|
|
|
|
if len(sensors.TemperatureCelsius) != 3 {
|
|
t.Errorf("TemperatureCelsius count = %d, want 3", len(sensors.TemperatureCelsius))
|
|
}
|
|
if sensors.TemperatureCelsius["cpu_package"] != 45.0 {
|
|
t.Errorf("cpu_package temp = %f, want 45.0", sensors.TemperatureCelsius["cpu_package"])
|
|
}
|
|
if sensors.FanRPM["cpu_fan"] != 1200.0 {
|
|
t.Errorf("cpu_fan RPM = %f, want 1200.0", sensors.FanRPM["cpu_fan"])
|
|
}
|
|
if len(sensors.GPU) != 1 {
|
|
t.Fatalf("GPU count = %d, want 1", len(sensors.GPU))
|
|
}
|
|
if sensors.GPU[0].UtilizationPercent == nil || *sensors.GPU[0].UtilizationPercent != 42 {
|
|
t.Fatalf("GPU utilization = %#v, want 42", sensors.GPU[0].UtilizationPercent)
|
|
}
|
|
if sensors.ThermalState == nil || sensors.ThermalState.Pressure != "nominal" {
|
|
t.Fatalf("ThermalState = %#v, want nominal state", sensors.ThermalState)
|
|
}
|
|
if sensors.ThermalState.LimitsPercent["cpu_speed_limit"] != 100 {
|
|
t.Errorf("cpu_speed_limit = %d, want 100", sensors.ThermalState.LimitsPercent["cpu_speed_limit"])
|
|
}
|
|
}
|
|
|
|
func TestRAIDArray_Fields(t *testing.T) {
|
|
array := RAIDArray{
|
|
Device: "/dev/md0",
|
|
Name: "myarray",
|
|
Level: "raid1",
|
|
State: "clean",
|
|
TotalDevices: 2,
|
|
ActiveDevices: 2,
|
|
WorkingDevices: 2,
|
|
FailedDevices: 0,
|
|
SpareDevices: 0,
|
|
UUID: "12345678:90abcdef:12345678:90abcdef",
|
|
RebuildPercent: 0,
|
|
RebuildSpeed: "",
|
|
Devices: []RAIDDevice{
|
|
{Device: "/dev/sda1", State: "active sync", Slot: 0},
|
|
{Device: "/dev/sdb1", State: "active sync", Slot: 1},
|
|
},
|
|
}
|
|
|
|
if array.Device != "/dev/md0" {
|
|
t.Errorf("Device = %q, want /dev/md0", array.Device)
|
|
}
|
|
if array.Level != "raid1" {
|
|
t.Errorf("Level = %q, want raid1", array.Level)
|
|
}
|
|
if array.State != "clean" {
|
|
t.Errorf("State = %q, want clean", array.State)
|
|
}
|
|
if len(array.Devices) != 2 {
|
|
t.Errorf("Devices count = %d, want 2", len(array.Devices))
|
|
}
|
|
}
|
|
|
|
func TestUnraidStorage_JSONMarshal(t *testing.T) {
|
|
report := Report{
|
|
Host: HostInfo{Hostname: "tower"},
|
|
Timestamp: time.Now(),
|
|
Unraid: &UnraidStorage{
|
|
ArrayStarted: true,
|
|
ArrayState: "STARTED",
|
|
SyncAction: "check",
|
|
SyncProgress: 42.5,
|
|
NumProtected: 2,
|
|
Disks: []UnraidDisk{
|
|
{
|
|
Name: "parity",
|
|
Device: "/dev/sdb",
|
|
Role: "parity",
|
|
Status: "online",
|
|
RawStatus: "DISK_OK",
|
|
Slot: 0,
|
|
},
|
|
{
|
|
Name: "disk1",
|
|
Device: "/dev/sdc",
|
|
Role: "data",
|
|
Status: "online",
|
|
RawStatus: "DISK_OK",
|
|
Slot: 1,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("marshal report: %v", err)
|
|
}
|
|
|
|
var decoded Report
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("unmarshal report: %v", err)
|
|
}
|
|
|
|
if decoded.Unraid == nil {
|
|
t.Fatal("expected unraid payload after round trip")
|
|
}
|
|
if !decoded.Unraid.ArrayStarted {
|
|
t.Fatalf("ArrayStarted = false, want true")
|
|
}
|
|
if decoded.Unraid.SyncAction != "check" {
|
|
t.Fatalf("SyncAction = %q, want check", decoded.Unraid.SyncAction)
|
|
}
|
|
if len(decoded.Unraid.Disks) != 2 {
|
|
t.Fatalf("disk count = %d, want 2", len(decoded.Unraid.Disks))
|
|
}
|
|
if decoded.Unraid.Disks[0].Role != "parity" {
|
|
t.Fatalf("first disk role = %q, want parity", decoded.Unraid.Disks[0].Role)
|
|
}
|
|
}
|
|
|
|
func TestRAIDDevice_Fields(t *testing.T) {
|
|
device := RAIDDevice{
|
|
Device: "/dev/sda1",
|
|
State: "active sync",
|
|
Slot: 0,
|
|
}
|
|
|
|
if device.Device != "/dev/sda1" {
|
|
t.Errorf("Device = %q, want /dev/sda1", device.Device)
|
|
}
|
|
if device.State != "active sync" {
|
|
t.Errorf("State = %q, want active sync", device.State)
|
|
}
|
|
if device.Slot != 0 {
|
|
t.Errorf("Slot = %d, want 0", device.Slot)
|
|
}
|
|
}
|
|
|
|
func TestRAIDArray_Degraded(t *testing.T) {
|
|
array := RAIDArray{
|
|
Device: "/dev/md1",
|
|
Level: "raid5",
|
|
State: "clean, degraded",
|
|
TotalDevices: 3,
|
|
ActiveDevices: 2,
|
|
WorkingDevices: 2,
|
|
FailedDevices: 1,
|
|
SpareDevices: 0,
|
|
Devices: []RAIDDevice{
|
|
{Device: "/dev/sda1", State: "active sync", Slot: 0},
|
|
{Device: "/dev/sdb1", State: "faulty", Slot: -1},
|
|
{Device: "/dev/sdc1", State: "active sync", Slot: 2},
|
|
},
|
|
}
|
|
|
|
if array.FailedDevices != 1 {
|
|
t.Errorf("FailedDevices = %d, want 1", array.FailedDevices)
|
|
}
|
|
if array.State != "clean, degraded" {
|
|
t.Errorf("State = %q, want 'clean, degraded'", array.State)
|
|
}
|
|
}
|
|
|
|
func TestRAIDArray_Rebuilding(t *testing.T) {
|
|
array := RAIDArray{
|
|
Device: "/dev/md2",
|
|
Level: "raid6",
|
|
State: "active, recovering",
|
|
RebuildPercent: 42.5,
|
|
RebuildSpeed: "50000K/sec",
|
|
}
|
|
|
|
if array.RebuildPercent != 42.5 {
|
|
t.Errorf("RebuildPercent = %f, want 42.5", array.RebuildPercent)
|
|
}
|
|
if array.RebuildSpeed != "50000K/sec" {
|
|
t.Errorf("RebuildSpeed = %q, want 50000K/sec", array.RebuildSpeed)
|
|
}
|
|
}
|
|
|
|
func TestReport_WithDisksAndNetwork(t *testing.T) {
|
|
report := Report{
|
|
Disks: []Disk{
|
|
{Device: "/dev/sda1", Mountpoint: "/", TotalBytes: 500000000000},
|
|
{Device: "/dev/sdb1", Mountpoint: "/data", TotalBytes: 1000000000000},
|
|
},
|
|
Network: []NetworkInterface{
|
|
{Name: "eth0", RXBytes: 1000000, TXBytes: 500000},
|
|
{Name: "eth1", RXBytes: 2000000, TXBytes: 1000000},
|
|
},
|
|
}
|
|
|
|
if len(report.Disks) != 2 {
|
|
t.Errorf("Disks count = %d, want 2", len(report.Disks))
|
|
}
|
|
if len(report.Network) != 2 {
|
|
t.Errorf("Network count = %d, want 2", len(report.Network))
|
|
}
|
|
}
|
|
|
|
func TestReport_WithSensorsAndRAID(t *testing.T) {
|
|
report := Report{
|
|
Sensors: Sensors{
|
|
TemperatureCelsius: map[string]float64{"cpu": 45.0},
|
|
},
|
|
RAID: []RAIDArray{
|
|
{Device: "/dev/md0", Level: "raid1", State: "clean"},
|
|
},
|
|
}
|
|
|
|
if len(report.Sensors.TemperatureCelsius) != 1 {
|
|
t.Errorf("TemperatureCelsius count = %d, want 1", len(report.Sensors.TemperatureCelsius))
|
|
}
|
|
if len(report.RAID) != 1 {
|
|
t.Errorf("RAID count = %d, want 1", len(report.RAID))
|
|
}
|
|
}
|
|
|
|
func TestReport_Tags(t *testing.T) {
|
|
report := Report{
|
|
Tags: []string{"production", "web", "us-east"},
|
|
}
|
|
|
|
if len(report.Tags) != 3 {
|
|
t.Errorf("Tags count = %d, want 3", len(report.Tags))
|
|
}
|
|
if report.Tags[0] != "production" {
|
|
t.Errorf("Tags[0] = %q, want production", report.Tags[0])
|
|
}
|
|
}
|
|
|
|
func TestReport_JSONOmitEmpty(t *testing.T) {
|
|
// Test that empty fields are omitted in JSON
|
|
report := Report{
|
|
Agent: AgentInfo{ID: "agent-1"},
|
|
Host: HostInfo{Hostname: "myhost"},
|
|
}
|
|
|
|
data, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal: %v", err)
|
|
}
|
|
|
|
jsonStr := string(data)
|
|
|
|
// These should not appear in JSON when empty
|
|
if contains(jsonStr, `"disks":[]`) {
|
|
t.Error("Empty disks should be omitted")
|
|
}
|
|
if contains(jsonStr, `"network":[]`) {
|
|
t.Error("Empty network should be omitted")
|
|
}
|
|
if contains(jsonStr, `"tags":[]`) {
|
|
t.Error("Empty tags should be omitted")
|
|
}
|
|
}
|
|
|
|
// Helper function
|
|
func contains(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestReportAvailabilityResultsJSONRoundTrip(t *testing.T) {
|
|
checkedAt := time.Date(2026, 7, 27, 10, 30, 0, 0, time.UTC)
|
|
report := Report{
|
|
Agent: AgentInfo{ID: "probe-agent"},
|
|
Host: HostInfo{Hostname: "probe-agent.local"},
|
|
AvailabilityResults: []AvailabilityProbeResult{
|
|
{TargetID: "target-1", Outcome: "reachable", LatencyMillis: 12, CheckedAt: checkedAt},
|
|
{TargetID: "target-2", Outcome: "unreachable", CheckedAt: checkedAt, Error: "icmp probe timed out"},
|
|
},
|
|
Timestamp: checkedAt,
|
|
}
|
|
|
|
data, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal: %v", err)
|
|
}
|
|
if !contains(string(data), `"availabilityResults":[`) {
|
|
t.Fatalf("availability results missing from payload: %s", data)
|
|
}
|
|
if contains(string(data), `"error":""`) {
|
|
t.Fatalf("empty probe error should be omitted: %s", data)
|
|
}
|
|
|
|
var decoded Report
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("Failed to unmarshal: %v", err)
|
|
}
|
|
if len(decoded.AvailabilityResults) != 2 {
|
|
t.Fatalf("availability results = %+v, want 2", decoded.AvailabilityResults)
|
|
}
|
|
first := decoded.AvailabilityResults[0]
|
|
if first.TargetID != "target-1" || first.Outcome != "reachable" || first.LatencyMillis != 12 {
|
|
t.Fatalf("first result = %+v", first)
|
|
}
|
|
if !first.CheckedAt.Equal(checkedAt) {
|
|
t.Fatalf("checkedAt = %v, want %v", first.CheckedAt, checkedAt)
|
|
}
|
|
if decoded.AvailabilityResults[1].Error != "icmp probe timed out" {
|
|
t.Fatalf("second result = %+v", decoded.AvailabilityResults[1])
|
|
}
|
|
|
|
// A report without probe assignments must not carry the section at all.
|
|
bare, err := json.Marshal(Report{Host: HostInfo{Hostname: "plain"}})
|
|
if err != nil {
|
|
t.Fatalf("Failed to marshal bare report: %v", err)
|
|
}
|
|
if contains(string(bare), "availabilityResults") {
|
|
t.Fatalf("empty availability results should be omitted: %s", bare)
|
|
}
|
|
}
|