Files
pulse/pkg/agents/host/report_test.go
T
courtmanr@gmail.com a9dad6a29c Run assigned availability checks from the host agent
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>
2026-07-27 11:13:04 +01:00

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)
}
}