mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-14 08:10:26 +00:00
13371a6b6c
Host agents key their identity on the machine-derived agent ID, so MSP template deployments that clone /etc/machine-id fold two physical machines at different sites into one host row whose reports overwrite each other (hostname, report IP, and interfaces flapping between sites), silently poisoning node-agent linking. Mirror the Docker host identity-collapse doctrine (#1584) for host report ingest: track hostname and report-IP revisits per resolved agent identity inside the monitoring-owned flap window, publish an active conflict as models.Host.IdentityConflict through unified resources, and warn on the Machines page. The report IP is tracked alongside the hostname because template fleets often reuse hostnames across sites (pve01 at two customers), leaving the address as the only field that betrays the clone. A one-time hostname rename never revisits and is not flagged; the conflict clears on its own once only one machine keeps reporting for the window. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
407 lines
10 KiB
Go
407 lines
10 KiB
Go
package models
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// --- 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 TestCloneDockerContainer_PreservesIndependentOOMEvidence(t *testing.T) {
|
|
oomKilled := false
|
|
src := DockerContainer{ID: "container-1", 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")
|
|
}
|
|
}
|
|
|
|
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")
|
|
}
|
|
}
|