mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 13:39:47 +00:00
14d3dc3b6c
Retain configured RAID members separately from source-native totals through collection, reports and canonical read views. Correct the healthy legacy mdadm tuple from #2369 without subtracting spares from mdstat requirements or suppressing real deficits, failures and recovery warnings. Preserve observed zero-active counts through fallback, and verify collector, wire, ingestion, health and canonical activation/resolution boundaries. Change-source: pulse-maintainer
903 lines
26 KiB
Go
903 lines
26 KiB
Go
package host
|
|
|
|
import (
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/tlsutil"
|
|
)
|
|
|
|
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 TestRAIDRequiredDevicesWireCompatibility(t *testing.T) {
|
|
for _, required := range []int{0, 4} {
|
|
report := Report{RAID: []RAIDArray{{Device: "/dev/md1", RequiredDevices: required, TotalDevices: 5, ActiveDevices: 4, WorkingDevices: 5, SpareDevices: 1}}}
|
|
data, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fields struct {
|
|
RAID []map[string]any `json:"raid"`
|
|
}
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if value, present := fields.RAID[0]["requiredDevices"]; present != (required != 0) || (present && value != float64(required)) {
|
|
t.Fatalf("required member presence/value = %v/%v in %s", present, value, data)
|
|
}
|
|
var got Report
|
|
if err := json.Unmarshal(data, &got); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.RAID[0].RequiredDevices != required || got.RAID[0].TotalDevices != 5 || got.RAID[0].SpareDevices != 1 {
|
|
t.Fatalf("RAID count round trip = %+v", got.RAID)
|
|
}
|
|
}
|
|
}
|
|
|
|
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 TestTypedPrivilegeHelperModuleStatusRoundTrip(t *testing.T) {
|
|
updatedAt := time.Date(2026, 8, 31, 12, 0, 0, 0, time.UTC)
|
|
report := Report{
|
|
Agent: AgentInfo{
|
|
Modules: []ModuleStatus{{
|
|
Name: ModuleNameTypedPrivilegeHelper,
|
|
Enabled: true,
|
|
State: "degraded",
|
|
LastError: "proxmox.lxc_filesystems: helper provider unavailable",
|
|
UpdatedAt: updatedAt,
|
|
}},
|
|
},
|
|
}
|
|
|
|
encoded, err := json.Marshal(report)
|
|
if err != nil {
|
|
t.Fatalf("marshal report: %v", err)
|
|
}
|
|
var decoded Report
|
|
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
|
t.Fatalf("unmarshal report: %v", err)
|
|
}
|
|
if len(decoded.Agent.Modules) != 1 {
|
|
t.Fatalf("modules = %+v", decoded.Agent.Modules)
|
|
}
|
|
module := decoded.Agent.Modules[0]
|
|
if module.Name != ModuleNameTypedPrivilegeHelper || !module.Enabled || module.State != "degraded" ||
|
|
module.LastError != "proxmox.lxc_filesystems: helper provider unavailable" || !module.UpdatedAt.Equal(updatedAt) {
|
|
t.Fatalf("typed helper module status = %+v", module)
|
|
}
|
|
}
|
|
|
|
func TestCustomSensorMetricJSONRoundTrip(t *testing.T) {
|
|
value := 7200.0
|
|
eventAt := time.Date(2026, 7, 30, 18, 0, 0, 0, time.UTC)
|
|
original := CustomSensorMetric{
|
|
ID: "backup_age",
|
|
Name: "Last file backup",
|
|
Group: "Main server",
|
|
Subgroup: "Backup",
|
|
Kind: CustomSensorKindTimestamp,
|
|
Unit: "seconds",
|
|
Value: &value,
|
|
Status: CustomSensorStatusWarning,
|
|
ObservedAt: time.Date(2026, 7, 30, 20, 0, 0, 0, time.UTC),
|
|
EventAt: &eventAt,
|
|
AlertOnError: true,
|
|
}
|
|
|
|
data, err := json.Marshal(original)
|
|
if err != nil {
|
|
t.Fatalf("marshal custom sensor metric: %v", err)
|
|
}
|
|
var decoded CustomSensorMetric
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatalf("unmarshal custom sensor metric: %v", err)
|
|
}
|
|
if decoded.Group != original.Group || decoded.Subgroup != original.Subgroup ||
|
|
decoded.Kind != CustomSensorKindTimestamp || decoded.Value == nil ||
|
|
*decoded.Value != value || decoded.EventAt == nil || !decoded.EventAt.Equal(eventAt) {
|
|
t.Fatalf("custom sensor metric did not round trip: %+v", decoded)
|
|
}
|
|
}
|
|
|
|
// The privilege block is agent-authored fact, not configuration: it must
|
|
// survive a JSON round trip exactly, and a legacy report without it must
|
|
// decode to nil rather than a zero-value profile pretending to be evidence.
|
|
func TestAgentInfoPrivilegeStatusRoundTrip(t *testing.T) {
|
|
agent := AgentInfo{
|
|
ID: "agent-privilege",
|
|
Privilege: &PrivilegeStatus{
|
|
RunningAsRoot: false,
|
|
ServiceUser: "pulse-agent",
|
|
CommandAuthority: "monitoring-only",
|
|
TypedHelper: true,
|
|
SmartctlHelper: true,
|
|
PctHelper: false,
|
|
},
|
|
}
|
|
encoded, err := json.Marshal(agent)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var decoded AgentInfo
|
|
if err := json.Unmarshal(encoded, &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if decoded.Privilege == nil ||
|
|
decoded.Privilege.RunningAsRoot ||
|
|
decoded.Privilege.ServiceUser != "pulse-agent" ||
|
|
decoded.Privilege.CommandAuthority != "monitoring-only" ||
|
|
!decoded.Privilege.TypedHelper ||
|
|
!decoded.Privilege.SmartctlHelper ||
|
|
decoded.Privilege.PctHelper {
|
|
t.Fatalf("privilege round trip = %+v", decoded.Privilege)
|
|
}
|
|
|
|
var legacy AgentInfo
|
|
if err := json.Unmarshal([]byte(`{"id":"legacy"}`), &legacy); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if legacy.Privilege != nil {
|
|
t.Fatalf("legacy report invented a privilege profile: %+v", legacy.Privilege)
|
|
}
|
|
}
|
|
|
|
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 TestDiskZFSDatasetsJSONRoundTrip(t *testing.T) {
|
|
input := Disk{
|
|
Device: "tank",
|
|
Type: "zfs",
|
|
ZFSDatasets: []ZFSDataset{{
|
|
Name: "tank/apps",
|
|
Type: "filesystem",
|
|
Mountpoint: "/tank/apps",
|
|
UsedBytes: 100,
|
|
AvailableBytes: 200,
|
|
ReferencedBytes: 50,
|
|
}},
|
|
}
|
|
data, err := json.Marshal(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var output Disk
|
|
if err := json.Unmarshal(data, &output); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(output.ZFSDatasets) != 1 || output.ZFSDatasets[0].Name != "tank/apps" {
|
|
t.Fatalf("zfs datasets = %#v", output.ZFSDatasets)
|
|
}
|
|
}
|
|
|
|
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
|
|
customValue := 7.0
|
|
observedAt := time.Date(2026, 7, 30, 19, 0, 0, 0, time.UTC)
|
|
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,
|
|
},
|
|
Custom: []CustomSensorMetric{{
|
|
ID: "pending_updates",
|
|
Name: "Pending updates",
|
|
Unit: "count",
|
|
Value: &customValue,
|
|
Status: CustomSensorStatusWarning,
|
|
ObservedAt: observedAt,
|
|
AlertOnError: true,
|
|
}},
|
|
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.Custom) != 1 || sensors.Custom[0].Value == nil || *sensors.Custom[0].Value != 7 {
|
|
t.Fatalf("custom sensors = %+v, want one typed reading", sensors.Custom)
|
|
}
|
|
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{
|
|
{
|
|
ObservationID: "observation-1", TargetID: "target-1", ConfigRevision: 7,
|
|
Outcome: "reachable", LatencyMillis: 12, CheckedAt: checkedAt,
|
|
Certificate: &tlsutil.CertificateObservation{
|
|
Subject: "pulse.example.test", TrustStatus: tlsutil.CertificateTrustTrusted,
|
|
ObservedAt: checkedAt, DNSNames: []string{"pulse.example.test"},
|
|
},
|
|
},
|
|
{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.ObservationID != "observation-1" || first.TargetID != "target-1" ||
|
|
first.ConfigRevision != 7 || 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 first.Certificate == nil || first.Certificate.TrustStatus != tlsutil.CertificateTrustTrusted || len(first.Certificate.DNSNames) != 1 {
|
|
t.Fatalf("certificate = %+v", first.Certificate)
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
|
|
func TestReportLibvirtInventoryJSONRoundTrip(t *testing.T) {
|
|
collectedAt := time.Date(2026, 7, 30, 12, 0, 0, 0, time.UTC)
|
|
report := Report{
|
|
Libvirt: &LibvirtInventory{
|
|
CollectedAt: collectedAt,
|
|
Domains: []LibvirtDomain{{
|
|
ID: "domain-a",
|
|
Name: "app",
|
|
State: "running",
|
|
VCPUs: 4,
|
|
CPUTimeNanoseconds: 3_000_000_000,
|
|
MemoryCurrentBytes: 2 << 30,
|
|
MemoryMaximumBytes: 4 << 30,
|
|
NetworkRXBytes: 100,
|
|
DiskWriteBytes: 200,
|
|
}},
|
|
},
|
|
}
|
|
|
|
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.Libvirt == nil ||
|
|
!decoded.Libvirt.CollectedAt.Equal(collectedAt) ||
|
|
len(decoded.Libvirt.Domains) != 1 {
|
|
t.Fatalf("libvirt inventory = %+v", decoded.Libvirt)
|
|
}
|
|
domain := decoded.Libvirt.Domains[0]
|
|
if domain.Name != "app" ||
|
|
domain.State != "running" ||
|
|
domain.VCPUs != 4 ||
|
|
domain.CPUTimeNanoseconds != 3_000_000_000 ||
|
|
domain.MemoryMaximumBytes != 4<<30 ||
|
|
domain.NetworkRXBytes != 100 ||
|
|
domain.DiskWriteBytes != 200 {
|
|
t.Fatalf("libvirt domain = %+v", domain)
|
|
}
|
|
|
|
bare, err := json.Marshal(Report{})
|
|
if err != nil {
|
|
t.Fatalf("marshal bare report: %v", err)
|
|
}
|
|
if contains(string(bare), "libvirt") {
|
|
t.Fatalf("nil libvirt inventory should be omitted: %s", bare)
|
|
}
|
|
}
|
|
|
|
func TestReportXCPNGInventoryJSONRoundTrip(t *testing.T) {
|
|
collectedAt := time.Date(2026, 7, 30, 15, 0, 0, 0, time.UTC)
|
|
report := Report{XCPNG: &XCPNGInventory{
|
|
PoolUUID: "11111111-1111-1111-1111-111111111111",
|
|
PoolName: "Lab Pool",
|
|
LocalHostUUID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
CollectedAt: collectedAt,
|
|
VMs: []XCPNGVM{{
|
|
UUID: "cccccccc-cccc-cccc-cccc-cccccccccccc",
|
|
Name: "database",
|
|
PowerState: "running",
|
|
VCPUs: 4,
|
|
MemoryActual: 4 << 30,
|
|
MemoryStaticMax: 8 << 30,
|
|
ResidentHostUUID: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
|
|
}},
|
|
}}
|
|
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.XCPNG == nil ||
|
|
decoded.XCPNG.PoolName != "Lab Pool" ||
|
|
!decoded.XCPNG.CollectedAt.Equal(collectedAt) ||
|
|
len(decoded.XCPNG.VMs) != 1 ||
|
|
decoded.XCPNG.VMs[0].MemoryActual != 4<<30 ||
|
|
decoded.XCPNG.VMs[0].MemoryStaticMax != 8<<30 {
|
|
t.Fatalf("XCP-ng inventory = %+v", decoded.XCPNG)
|
|
}
|
|
}
|
|
|
|
func TestReportProxmoxLXCInventoryJSONRoundTrip(t *testing.T) {
|
|
collectedAt := time.Date(2026, 7, 30, 20, 30, 0, 0, time.UTC)
|
|
report := Report{ProxmoxLXC: &ProxmoxLXCInventory{
|
|
CollectedAt: collectedAt,
|
|
Containers: []ProxmoxLXCContainer{{
|
|
VMID: 200,
|
|
Name: "app",
|
|
Disks: []Disk{{
|
|
Device: "local:subvol-200-disk-0",
|
|
Mountpoint: "/",
|
|
Type: "rootfs",
|
|
TotalBytes: 20 << 30,
|
|
UsedBytes: 5 << 30,
|
|
FreeBytes: 15 << 30,
|
|
Usage: 25,
|
|
}},
|
|
}},
|
|
}}
|
|
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.ProxmoxLXC == nil ||
|
|
!decoded.ProxmoxLXC.CollectedAt.Equal(collectedAt) ||
|
|
len(decoded.ProxmoxLXC.Containers) != 1 ||
|
|
decoded.ProxmoxLXC.Containers[0].VMID != 200 ||
|
|
decoded.ProxmoxLXC.Containers[0].Disks[0].Mountpoint != "/" {
|
|
t.Fatalf("Proxmox LXC inventory = %+v", decoded.ProxmoxLXC)
|
|
}
|
|
|
|
bare, err := json.Marshal(Report{})
|
|
if err != nil {
|
|
t.Fatalf("marshal bare report: %v", err)
|
|
}
|
|
if contains(string(bare), "proxmoxLxc") {
|
|
t.Fatalf("nil Proxmox LXC inventory should be omitted: %s", bare)
|
|
}
|
|
}
|
|
|
|
func TestDiskExplicitIncludeWireCompatibility(t *testing.T) {
|
|
for _, included := range []bool{false, true} {
|
|
disk := Disk{Mountpoint: "/var/log", Type: "tmpfs", TotalBytes: 1024, ExplicitlyIncluded: included}
|
|
data, err := json.Marshal(disk)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var fields map[string]json.RawMessage
|
|
if err := json.Unmarshal(data, &fields); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, present := fields["explicitlyIncluded"]; present != included {
|
|
t.Fatalf("include field presence = %v, want %v: %s", present, included, data)
|
|
}
|
|
var decoded Disk
|
|
if err := json.Unmarshal(data, &decoded); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if decoded.ExplicitlyIncluded != included || decoded.Mountpoint != disk.Mountpoint || decoded.TotalBytes != disk.TotalBytes || decoded.Type != disk.Type {
|
|
t.Fatalf("round trip = %+v, want %+v", decoded, disk)
|
|
}
|
|
}
|
|
}
|