Files
pulse/pkg/agents/host/report_test.go
T
pulse-triage[bot] 14d3dc3b6c Fix MD RAID spare counts without hiding member failures
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
2026-10-01 19:56:47 +01:00

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