mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
b9c2a90ee7
A Proxmox disk that only the node's sensors report never raised a temperature alert: CheckDiskHealth judges health and wearout only, and unified evaluation covers TrueNAS disks, not PVE ones. Physical Disks could read the disk Running Hot under the per-type thresholds while alerts stayed silent, and whether a disk alerted depended on whether a Pulse agent happened to run on the node. The PVE disk poller now hands each disk's current reading to CheckProxmoxDiskTemperature, which judges it by the same disk temperature policy as agent and TrueNAS disks (DiskTempByType under the agent Disk Temp default) under the disk's existing PVE alert reference. A disk a still-reporting linked agent lists in its SMART report is the agent's: CheckHost alerts on it and the PVE alert closes as moved to the agent, so no disk alerts twice. A retained reading holds the alert, an excluded device or a switched-off policy closes it, warnings wait the same 300s stability window as other disk temperatures, a config save re-judges the alert per disk type, and resource history owns its rows by the disk's recorded identity like the health and wearout alerts.
536 lines
15 KiB
Go
536 lines
15 KiB
Go
package models
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// clonePBSInstance must carry scalar identity fields, including the
|
|
// PBS-reported node hostname that connected-system grouping relies on, and
|
|
// its collection copies must stay independent of the source.
|
|
func TestClonePBSInstance_PreservesReportedNodeName(t *testing.T) {
|
|
src := PBSInstance{
|
|
ID: "pbs-backup",
|
|
Name: "backup",
|
|
Host: "https://192.0.2.40:8007",
|
|
NodeName: "pbs01",
|
|
Datastores: []PBSDatastore{{Name: "internal"}},
|
|
}
|
|
|
|
dest := clonePBSInstance(src)
|
|
if dest.NodeName != "pbs01" {
|
|
t.Fatalf("NodeName = %q, want %q", dest.NodeName, "pbs01")
|
|
}
|
|
|
|
dest.Datastores[0].Name = "mutated"
|
|
if src.Datastores[0].Name != "internal" {
|
|
t.Fatalf("clone datastores share backing array with source")
|
|
}
|
|
}
|
|
|
|
// --- Pointer clone helpers ---
|
|
|
|
func TestCloneBoolPtr_Nil(t *testing.T) {
|
|
if cloneBoolPtr(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneBoolPtr_Value(t *testing.T) {
|
|
v := true
|
|
got := cloneBoolPtr(&v)
|
|
if got == nil || *got != true {
|
|
t.Error("value should be preserved")
|
|
}
|
|
v = false
|
|
if *got != true {
|
|
t.Error("clone should be independent of source")
|
|
}
|
|
}
|
|
|
|
func TestCloneHostAndZFSPoolIsolateZFSDatasets(t *testing.T) {
|
|
host := Host{ZFSPools: []HostZFSPool{{
|
|
Name: "tank",
|
|
Datasets: []ZFSDataset{{Name: "tank/apps", UsedBytes: 100}},
|
|
}}}
|
|
hostClone := cloneHost(host)
|
|
hostClone.ZFSPools[0].Datasets[0].Name = "mutated"
|
|
if host.ZFSPools[0].Datasets[0].Name != "tank/apps" {
|
|
t.Fatal("host clone aliased zfs datasets")
|
|
}
|
|
|
|
pool := &ZFSPool{Name: "tank", Datasets: []ZFSDataset{{Name: "tank/apps"}}}
|
|
poolClone := cloneZFSPool(pool)
|
|
poolClone.Datasets[0].Name = "mutated"
|
|
if pool.Datasets[0].Name != "tank/apps" {
|
|
t.Fatal("pool clone aliased zfs datasets")
|
|
}
|
|
}
|
|
|
|
func TestCloneHostIsolatesAgentPrivilege(t *testing.T) {
|
|
host := Host{AgentPrivilege: &AgentPrivilegeStatus{
|
|
RunningAsRoot: false,
|
|
ServiceUser: "pulse-agent",
|
|
}}
|
|
hostClone := cloneHost(host)
|
|
if hostClone.AgentPrivilege == nil {
|
|
t.Fatal("host clone dropped agent privilege")
|
|
}
|
|
hostClone.AgentPrivilege.ServiceUser = "mutated"
|
|
if host.AgentPrivilege.ServiceUser != "pulse-agent" {
|
|
t.Fatal("host clone aliased agent privilege")
|
|
}
|
|
}
|
|
|
|
func TestCloneDockerHostIsolatesAgentModuleStatus(t *testing.T) {
|
|
src := DockerHost{AgentModules: []AgentModuleStatus{{
|
|
Name: "typed-privilege-helper", State: "degraded", LastError: "container.inventory: helper provider unavailable",
|
|
}}}
|
|
clone := cloneDockerHost(src)
|
|
if len(clone.AgentModules) != 1 || clone.AgentModules[0].LastError != src.AgentModules[0].LastError {
|
|
t.Fatalf("clone dropped Docker agent module status: %+v", clone.AgentModules)
|
|
}
|
|
clone.AgentModules[0].LastError = "mutated"
|
|
if src.AgentModules[0].LastError != "container.inventory: helper provider unavailable" {
|
|
t.Fatalf("Docker host clone aliased agent module status: %+v", src.AgentModules)
|
|
}
|
|
}
|
|
|
|
func TestCloneDockerContainer_PreservesIndependentOOMEvidence(t *testing.T) {
|
|
oomKilled := false
|
|
src := DockerContainer{
|
|
ID: "container-1",
|
|
HealthcheckTargets: []string{"dependency-service"},
|
|
OOMKilled: &oomKilled,
|
|
}
|
|
|
|
got := cloneDockerContainer(src)
|
|
if got.OOMKilled == nil || *got.OOMKilled {
|
|
t.Fatalf("OOMKilled = %v, want explicit false", got.OOMKilled)
|
|
}
|
|
oomKilled = true
|
|
if *got.OOMKilled {
|
|
t.Fatal("cloned Docker container must own its OOM evidence value")
|
|
}
|
|
got.HealthcheckTargets[0] = "mutated"
|
|
if src.HealthcheckTargets[0] != "dependency-service" {
|
|
t.Fatal("cloned Docker container must own its health-check targets")
|
|
}
|
|
}
|
|
|
|
func TestCloneDockerHost_PreservesIndependentIdentityConflict(t *testing.T) {
|
|
src := DockerHost{
|
|
ID: "docker-host-1",
|
|
IdentityConflict: &DockerHostIdentityConflict{
|
|
Hostnames: []string{"clone-a", "clone-b"},
|
|
MachineIDs: []string{"machine-a", "machine-b"},
|
|
},
|
|
}
|
|
|
|
got := cloneDockerHost(src)
|
|
if got.IdentityConflict == nil {
|
|
t.Fatal("clone dropped identity conflict evidence")
|
|
}
|
|
if len(got.IdentityConflict.Hostnames) != 2 || got.IdentityConflict.Hostnames[0] != "clone-a" {
|
|
t.Fatalf("hostnames = %v, want [clone-a clone-b]", got.IdentityConflict.Hostnames)
|
|
}
|
|
src.IdentityConflict.Hostnames[0] = "mutated"
|
|
src.IdentityConflict.MachineIDs[0] = "mutated"
|
|
if got.IdentityConflict.Hostnames[0] != "clone-a" || got.IdentityConflict.MachineIDs[0] != "machine-a" {
|
|
t.Fatal("cloned Docker host must own its identity conflict slices")
|
|
}
|
|
|
|
if clean := cloneDockerHost(DockerHost{ID: "healthy"}); clean.IdentityConflict != nil {
|
|
t.Fatalf("healthy host should clone with nil conflict, got %+v", clean.IdentityConflict)
|
|
}
|
|
}
|
|
|
|
func TestCloneHost_PreservesIndependentIdentityConflict(t *testing.T) {
|
|
src := Host{
|
|
ID: "host-1",
|
|
IdentityConflict: &HostIdentityConflict{
|
|
Hostnames: []string{"clone-a", "clone-b"},
|
|
ReportIPs: []string{"192.168.1.10", "10.0.0.10"},
|
|
},
|
|
}
|
|
|
|
got := cloneHost(src)
|
|
if got.IdentityConflict == nil {
|
|
t.Fatal("clone dropped identity conflict evidence")
|
|
}
|
|
if len(got.IdentityConflict.Hostnames) != 2 || got.IdentityConflict.Hostnames[0] != "clone-a" {
|
|
t.Fatalf("hostnames = %v, want [clone-a clone-b]", got.IdentityConflict.Hostnames)
|
|
}
|
|
src.IdentityConflict.Hostnames[0] = "mutated"
|
|
src.IdentityConflict.ReportIPs[0] = "mutated"
|
|
if got.IdentityConflict.Hostnames[0] != "clone-a" || got.IdentityConflict.ReportIPs[0] != "192.168.1.10" {
|
|
t.Fatal("cloned host must own its identity conflict slices")
|
|
}
|
|
|
|
if clean := cloneHost(Host{ID: "healthy"}); clean.IdentityConflict != nil {
|
|
t.Fatalf("healthy host should clone with nil conflict, got %+v", clean.IdentityConflict)
|
|
}
|
|
}
|
|
|
|
func TestCloneFloat64Ptr_Nil(t *testing.T) {
|
|
if cloneFloat64Ptr(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneFloat64Ptr_Value(t *testing.T) {
|
|
v := 3.14
|
|
got := cloneFloat64Ptr(&v)
|
|
if got == nil || *got != 3.14 {
|
|
t.Error("value should be preserved")
|
|
}
|
|
v = 0
|
|
if *got != 3.14 {
|
|
t.Error("clone should be independent of source")
|
|
}
|
|
}
|
|
|
|
func TestCloneIntPtr_Nil(t *testing.T) {
|
|
if cloneIntPtr(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneIntPtr_Value(t *testing.T) {
|
|
v := 42
|
|
got := cloneIntPtr(&v)
|
|
if got == nil || *got != 42 {
|
|
t.Error("value should be preserved")
|
|
}
|
|
v = 0
|
|
if *got != 42 {
|
|
t.Error("clone should be independent")
|
|
}
|
|
}
|
|
|
|
func TestCloneInt64Ptr_Nil(t *testing.T) {
|
|
if cloneInt64Ptr(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneInt64Ptr_Value(t *testing.T) {
|
|
v := int64(999)
|
|
got := cloneInt64Ptr(&v)
|
|
if got == nil || *got != 999 {
|
|
t.Error("value should be preserved")
|
|
}
|
|
v = 0
|
|
if *got != 999 {
|
|
t.Error("clone should be independent")
|
|
}
|
|
}
|
|
|
|
// --- Map clone helpers ---
|
|
|
|
func TestCloneStringMap_Nil(t *testing.T) {
|
|
if cloneStringMap(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneStringMap_Isolation(t *testing.T) {
|
|
src := map[string]string{"a": "1", "b": "2"}
|
|
dst := cloneStringMap(src)
|
|
dst["c"] = "3"
|
|
if _, ok := src["c"]; ok {
|
|
t.Error("mutating clone should not affect source")
|
|
}
|
|
if len(dst) != 3 {
|
|
t.Error("clone should have the added entry")
|
|
}
|
|
}
|
|
|
|
func TestCloneStringFloat64Map_Nil(t *testing.T) {
|
|
if cloneStringFloat64Map(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneStringFloat64Map_Isolation(t *testing.T) {
|
|
src := map[string]float64{"temp": 42.5}
|
|
dst := cloneStringFloat64Map(src)
|
|
dst["fan"] = 1200.0
|
|
if _, ok := src["fan"]; ok {
|
|
t.Error("mutating clone should not affect source")
|
|
}
|
|
}
|
|
|
|
// --- Slice clone helpers ---
|
|
|
|
func TestCloneCoreTemps_Nil(t *testing.T) {
|
|
if cloneCoreTemps(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneCoreTemps_Isolation(t *testing.T) {
|
|
src := []CoreTemp{{Core: 0, Temp: 45.0}}
|
|
dst := cloneCoreTemps(src)
|
|
dst[0].Temp = 99.0
|
|
if src[0].Temp != 45.0 {
|
|
t.Error("mutating clone should not affect source")
|
|
}
|
|
}
|
|
|
|
func TestCloneGPUTemps_Nil(t *testing.T) {
|
|
if cloneGPUTemps(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneNVMeTemps_Nil(t *testing.T) {
|
|
if cloneNVMeTemps(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneDiskTemps_Nil(t *testing.T) {
|
|
if cloneDiskTemps(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
// --- Temperature clone ---
|
|
|
|
func TestCloneTemperature_Nil(t *testing.T) {
|
|
if cloneTemperature(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneTemperature_Isolation(t *testing.T) {
|
|
src := &Temperature{
|
|
Cores: []CoreTemp{{Core: 0, Temp: 45.0}},
|
|
NVMe: []NVMeTemp{{Device: "nvme0", Temp: 35.0}},
|
|
}
|
|
dst := cloneTemperature(src)
|
|
dst.Cores[0].Temp = 99.0
|
|
if src.Cores[0].Temp != 45.0 {
|
|
t.Error("mutating cloned temperature should not affect source")
|
|
}
|
|
}
|
|
|
|
// --- Guest network interface clone ---
|
|
|
|
func TestCloneGuestNetworkInterfaces_Nil(t *testing.T) {
|
|
if cloneGuestNetworkInterfaces(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneGuestNetworkInterfaces_Isolation(t *testing.T) {
|
|
src := []GuestNetworkInterface{
|
|
{Name: "eth0", Addresses: []string{"192.168.1.10"}},
|
|
}
|
|
dst := cloneGuestNetworkInterfaces(src)
|
|
dst[0].Addresses = append(dst[0].Addresses, "10.0.0.1")
|
|
if len(src[0].Addresses) != 1 {
|
|
t.Error("mutating clone addresses should not affect source")
|
|
}
|
|
}
|
|
|
|
// --- Host network interface clone ---
|
|
|
|
func TestCloneHostNetworkInterfaces_Nil(t *testing.T) {
|
|
if cloneHostNetworkInterfaces(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneHostNetworkInterfaces_Isolation(t *testing.T) {
|
|
src := []HostNetworkInterface{
|
|
{Name: "eth0", Addresses: []string{"192.168.1.10"}, MAC: "00:11:22:33:44:55"},
|
|
}
|
|
dst := cloneHostNetworkInterfaces(src)
|
|
dst[0].Addresses = append(dst[0].Addresses, "10.0.0.1")
|
|
if len(src[0].Addresses) != 1 {
|
|
t.Error("mutating clone addresses should not affect source")
|
|
}
|
|
}
|
|
|
|
// --- SMART attributes clone ---
|
|
|
|
func TestCloneSMARTAttributes_Nil(t *testing.T) {
|
|
if cloneSMARTAttributes(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneSMARTAttributes_Isolation(t *testing.T) {
|
|
pending := int64(3)
|
|
media := int64(1)
|
|
src := &SMARTAttributes{
|
|
PendingSectors: &pending,
|
|
MediaErrors: &media,
|
|
}
|
|
dst := cloneSMARTAttributes(src)
|
|
newVal := int64(99)
|
|
dst.PendingSectors = &newVal
|
|
if *src.PendingSectors != 3 {
|
|
t.Error("mutating cloned SMART attributes should not affect source")
|
|
}
|
|
}
|
|
|
|
// --- Node clone ---
|
|
|
|
func TestCloneNode_Isolation(t *testing.T) {
|
|
src := Node{
|
|
ID: "node-1",
|
|
Name: "pve1",
|
|
Status: "online",
|
|
Temperature: &Temperature{
|
|
Cores: []CoreTemp{{Core: 0, Temp: 45.0}},
|
|
},
|
|
}
|
|
dst := cloneNode(src)
|
|
dst.Name = "changed"
|
|
dst.Temperature.Cores[0].Temp = 99.0
|
|
if src.Name != "pve1" {
|
|
t.Error("clone should not affect source name")
|
|
}
|
|
if src.Temperature.Cores[0].Temp != 45.0 {
|
|
t.Error("clone temperature should be independent")
|
|
}
|
|
}
|
|
|
|
func TestCloneNodes_Nil(t *testing.T) {
|
|
if cloneNodes(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneNodes_Empty(t *testing.T) {
|
|
got := cloneNodes([]Node{})
|
|
if got != nil {
|
|
t.Error("empty slice should clone to nil (consistent with nil input)")
|
|
}
|
|
}
|
|
|
|
// --- VM clone ---
|
|
|
|
func TestCloneVM_NetworkIsolation(t *testing.T) {
|
|
src := VM{
|
|
ID: "vm-1",
|
|
Name: "web",
|
|
NetworkInterfaces: []GuestNetworkInterface{
|
|
{Name: "eth0", Addresses: []string{"192.168.1.10"}},
|
|
},
|
|
}
|
|
dst := cloneVM(src)
|
|
dst.NetworkInterfaces[0].Addresses = append(dst.NetworkInterfaces[0].Addresses, "10.0.0.1")
|
|
if len(src.NetworkInterfaces[0].Addresses) != 1 {
|
|
t.Error("clone should not affect source network interfaces")
|
|
}
|
|
}
|
|
|
|
// --- Host clone ---
|
|
|
|
func TestCloneHost_MapIsolation(t *testing.T) {
|
|
thermalWarningLevel := 0
|
|
src := Host{
|
|
ID: "host-1",
|
|
Hostname: "server1",
|
|
Tags: []string{"env:prod"},
|
|
Sensors: HostSensorSummary{
|
|
TemperatureCelsius: map[string]float64{"cpu": 42.0},
|
|
ThermalState: &HostThermalState{
|
|
Source: "pmset",
|
|
Pressure: "nominal",
|
|
ThermalWarningLevel: &thermalWarningLevel,
|
|
LimitsPercent: map[string]int{"cpu_speed_limit": 100},
|
|
},
|
|
},
|
|
PackageUpdates: &HostPackageUpdateStatus{
|
|
Supported: true,
|
|
Packages: []HostPackageUpdate{{Name: "openssl"}},
|
|
},
|
|
StorageCleanup: &HostStorageCleanupStatus{Provider: "apt-package-cache", ReclaimableBytes: 512},
|
|
}
|
|
dst := cloneHost(src)
|
|
dst.Tags = append(dst.Tags, "new:val")
|
|
dst.Sensors.TemperatureCelsius["gpu"] = 65.0
|
|
dst.Sensors.ThermalState.LimitsPercent["cpu_speed_limit"] = 80
|
|
*dst.Sensors.ThermalState.ThermalWarningLevel = 1
|
|
dst.PackageUpdates.Packages[0].Name = "mutated"
|
|
dst.StorageCleanup.Provider = "mutated"
|
|
if len(src.Tags) != 1 {
|
|
t.Error("clone tags should be independent")
|
|
}
|
|
if _, ok := src.Sensors.TemperatureCelsius["gpu"]; ok {
|
|
t.Error("clone sensor map should be independent")
|
|
}
|
|
if src.Sensors.ThermalState.LimitsPercent["cpu_speed_limit"] != 100 {
|
|
t.Error("clone thermal limit map should be independent")
|
|
}
|
|
if *src.Sensors.ThermalState.ThermalWarningLevel != 0 {
|
|
t.Error("clone thermal warning pointer should be independent")
|
|
}
|
|
if src.PackageUpdates.Packages[0].Name != "openssl" {
|
|
t.Error("clone package update inventory should be independent")
|
|
}
|
|
if src.StorageCleanup.Provider != "apt-package-cache" {
|
|
t.Error("clone storage cleanup posture should be independent")
|
|
}
|
|
}
|
|
|
|
func TestCloneHosts_Nil(t *testing.T) {
|
|
if cloneHosts(nil) != nil {
|
|
t.Error("nil should clone to nil")
|
|
}
|
|
}
|
|
|
|
func TestCloneHostLibvirtInventoryIsolation(t *testing.T) {
|
|
src := Host{Libvirt: &HostLibvirtInventory{
|
|
Domains: []HostLibvirtDomain{{ID: "domain-a", Name: "app"}},
|
|
}}
|
|
dst := cloneHost(src)
|
|
if dst.Libvirt == nil || len(dst.Libvirt.Domains) != 1 {
|
|
t.Fatalf("cloned libvirt inventory = %+v", dst.Libvirt)
|
|
}
|
|
dst.Libvirt.Domains[0].Name = "mutated"
|
|
if src.Libvirt.Domains[0].Name != "app" {
|
|
t.Fatal("clone libvirt inventory aliases source domains")
|
|
}
|
|
}
|
|
|
|
func TestCloneHostXCPNGInventoryIsolation(t *testing.T) {
|
|
src := Host{XCPNG: &HostXCPNGInventory{
|
|
PoolUUID: "11111111-1111-1111-1111-111111111111",
|
|
VMs: []HostXCPNGVM{{UUID: "vm-a", Name: "app"}},
|
|
}}
|
|
dst := cloneHost(src)
|
|
if dst.XCPNG == nil || len(dst.XCPNG.VMs) != 1 {
|
|
t.Fatalf("cloned XCP-ng inventory = %+v", dst.XCPNG)
|
|
}
|
|
dst.XCPNG.VMs[0].Name = "mutated"
|
|
if src.XCPNG.VMs[0].Name != "app" {
|
|
t.Fatal("cloned XCP-ng inventory aliases source VMs")
|
|
}
|
|
}
|
|
|
|
// AgentSMARTReported is internal poll evidence: a state snapshot copy keeps it
|
|
// for the alert check, and it never reaches an API or websocket payload.
|
|
func TestPhysicalDiskAgentSMARTReportedStaysInternal(t *testing.T) {
|
|
disk := PhysicalDisk{ID: "lab-pve1-sda", DevPath: "/dev/sda", AgentSMARTReported: true}
|
|
if clone := clonePhysicalDisk(disk); !clone.AgentSMARTReported {
|
|
t.Fatalf("clone dropped AgentSMARTReported")
|
|
}
|
|
if clones := clonePhysicalDisks([]PhysicalDisk{disk}); !clones[0].AgentSMARTReported {
|
|
t.Fatalf("slice clone dropped AgentSMARTReported")
|
|
}
|
|
payload, err := json.Marshal(disk)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if strings.Contains(strings.ToLower(string(payload)), "agentsmartreported") {
|
|
t.Fatalf("AgentSMARTReported leaked into JSON: %s", payload)
|
|
}
|
|
}
|