mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
0c130a4855
The poller reads the previous poll's nodes back from the unified read state, but previousNodeFromView returned the registry's unified resource ID and no temperature. Every lookup keyed on the Proxmox source ID missed: nodeLastOnline in the offline grace policy, the temperature carry, the CPU low/record tracking (reset to the current reading every poll) and the node memory carry. When a whole instance poll failed, markPVEInstanceNodesUnreachable wrote the projected nodes back, so each failed poll re-ingested the node under a new unified ID. Cluster members also lost the cluster identity their canonical ID derives from, so they changed ID for the length of an outage. The projection now returns the source ID, the stored temperature details, the cluster name and the linked agent ID, and previousNodesForInstance restores the config-derived identity stamps (provider-scoped flag, native aliases, TLS fingerprint) the way pollPVENode writes them. Reviving those paths needed four bounds: - The node memory carry uses the poller's own validated snapshot rather than the read state, which holds the agent's reading on a node merged with a host agent, and keeps used + cache + free equal to the total. - A carried temperature keeps a lapsed host agent's lease. A reading stamped no later than the agent's last report is not carried, so the five-minute carry window cannot hold a silent agent's reading in alerts and history past its lease, which #2582 guarantees. - A carried reading keeps the node's CPU and GPU temperatures only. Its SMART and NVMe rows also stamp the node's physical disks, so a disk that went to standby would be stamped and recorded with its pre-sleep value. - Outage preservation and authoritative membership reconciliation keep a node's temperature only under those rules and drop it when the node goes offline. TestPollPVEInstanceMarksStaleNodesOfflineWhenGetNodesReturnsEmpty relied on the dead nodeLastOnline lookup and now ages that sighting too. The silent-agent test from #2582 now rebuilds the store as production does and feeds the polled node back, which is what exercises the carry.
422 lines
12 KiB
Go
422 lines
12 KiB
Go
package monitoring
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
|
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
|
|
)
|
|
|
|
type nodeNetworkFailureClient struct {
|
|
PVEClientInterface
|
|
err error
|
|
calls *int
|
|
}
|
|
|
|
func (c nodeNetworkFailureClient) GetNodeNetworkInterfaces(context.Context, string) ([]proxmox.NodeNetworkInterface, error) {
|
|
if c.calls != nil {
|
|
*c.calls++
|
|
}
|
|
return nil, c.err
|
|
}
|
|
|
|
func TestPVENodeNetworkFailurePreservesLastKnownInventory(t *testing.T) {
|
|
previous := []models.Node{{
|
|
ID: "lab-pve1", Instance: "lab", Name: "pve1",
|
|
NetworkInterfaces: []models.HostNetworkInterface{{Name: "vmbr0", Addresses: []string{"192.0.2.10/24"}}},
|
|
}}
|
|
client := nodeNetworkFailureClient{err: errors.New("temporary network inventory failure")}
|
|
|
|
got := collectPVENodeNetworkInterfaces(context.Background(), client, "lab", "lab-pve1", "pve1", previous)
|
|
if !reflect.DeepEqual(got, previous[0].NetworkInterfaces) {
|
|
t.Fatalf("network interfaces = %#v, want previous %#v", got, previous[0].NetworkInterfaces)
|
|
}
|
|
got[0].Addresses[0] = "mutated"
|
|
if previous[0].NetworkInterfaces[0].Addresses[0] != "192.0.2.10/24" {
|
|
t.Fatal("last-known interface fallback aliases previous state")
|
|
}
|
|
}
|
|
|
|
func TestPVENodeNetworkRefreshIsBoundedWithinPollingWindow(t *testing.T) {
|
|
calls := 0
|
|
previous := []models.Node{{
|
|
ID: "lab-pve1", Instance: "lab", Name: "pve1", LastSeen: time.Now(),
|
|
NetworkInterfaces: []models.HostNetworkInterface{{Name: "vmbr0", Addresses: []string{"192.0.2.10/24"}}},
|
|
}}
|
|
client := nodeNetworkFailureClient{err: errors.New("must not be called"), calls: &calls}
|
|
|
|
got := collectPVENodeNetworkInterfaces(context.Background(), client, "lab", "lab-pve1", "pve1", previous)
|
|
if calls != 0 {
|
|
t.Fatalf("network inventory calls = %d, want 0 within refresh window", calls)
|
|
}
|
|
if !reflect.DeepEqual(got, previous[0].NetworkInterfaces) {
|
|
t.Fatalf("network interfaces = %#v, want previous %#v", got, previous[0].NetworkInterfaces)
|
|
}
|
|
}
|
|
|
|
func TestResolveNodeMemoryCharacterization(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
|
|
const gib = uint64(1024 * 1024 * 1024)
|
|
|
|
tests := []struct {
|
|
name string
|
|
memory *proxmox.MemoryStatus
|
|
rrdPoints []proxmox.NodeRRDPoint
|
|
wantSource string
|
|
wantFallback string
|
|
wantUsed uint64
|
|
wantRawSource string
|
|
}{
|
|
{
|
|
name: "missing MemAvailable derives from free buffers cached",
|
|
memory: &proxmox.MemoryStatus{
|
|
Total: 32 * gib,
|
|
Used: 26 * gib,
|
|
Free: 2 * gib,
|
|
Buffers: 3 * gib,
|
|
Cached: 7 * gib,
|
|
},
|
|
wantSource: "derived-free-buffers-cached",
|
|
wantFallback: "",
|
|
wantUsed: 20 * gib,
|
|
wantRawSource: "node-status",
|
|
},
|
|
{
|
|
name: "proxmox 8.4 field drift derives from total minus used gap",
|
|
memory: &proxmox.MemoryStatus{
|
|
Total: 134794743808,
|
|
Used: 107351023616,
|
|
Free: 6471057408,
|
|
},
|
|
wantSource: "derived-total-minus-used",
|
|
wantFallback: "node-status-total-minus-used",
|
|
wantUsed: 107351023616,
|
|
wantRawSource: "node-status-total-minus-used",
|
|
},
|
|
{
|
|
name: "missing cache fields falls back to rrd memused",
|
|
memory: &proxmox.MemoryStatus{
|
|
Total: 16 * gib,
|
|
Used: 16 * gib,
|
|
Free: 0,
|
|
},
|
|
rrdPoints: []proxmox.NodeRRDPoint{{
|
|
MemTotal: floatPtr(float64(16 * gib)),
|
|
MemUsed: floatPtr(float64(6 * gib)),
|
|
}},
|
|
wantSource: "rrd-memused",
|
|
wantFallback: "rrd-memused",
|
|
wantUsed: 6 * gib,
|
|
wantRawSource: "rrd-memused",
|
|
},
|
|
{
|
|
name: "node status has only free so rrd memavailable is preferred over total-minus-free",
|
|
memory: &proxmox.MemoryStatus{
|
|
Total: 8 * gib,
|
|
Used: 7 * gib,
|
|
Free: 1 * gib,
|
|
},
|
|
rrdPoints: []proxmox.NodeRRDPoint{{
|
|
MemTotal: floatPtr(float64(8 * gib)),
|
|
MemAvailable: floatPtr(float64(4 * gib)),
|
|
}},
|
|
wantSource: "rrd-memavailable",
|
|
wantFallback: "rrd-memavailable",
|
|
wantUsed: 4 * gib,
|
|
wantRawSource: "rrd-memavailable",
|
|
},
|
|
{
|
|
name: "explicit zero RRD memused is a valid idle sample",
|
|
memory: &proxmox.MemoryStatus{
|
|
Total: 8 * gib,
|
|
Used: 7 * gib,
|
|
Free: 1 * gib,
|
|
},
|
|
rrdPoints: []proxmox.NodeRRDPoint{{
|
|
MemTotal: floatPtr(float64(8 * gib)),
|
|
MemUsed: floatPtr(0),
|
|
}},
|
|
wantSource: "rrd-memused",
|
|
wantFallback: "rrd-memused",
|
|
wantUsed: 0,
|
|
wantRawSource: "rrd-memused",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
mon := newTestPVEMonitor("test")
|
|
defer mon.alertManager.Stop()
|
|
defer mon.notificationMgr.Stop()
|
|
|
|
client := &stubPVEClient{rrdPoints: tt.rrdPoints}
|
|
|
|
memory, source, fallback, raw, ok := mon.resolveNodeMemory(
|
|
context.Background(),
|
|
client,
|
|
"test",
|
|
"node1",
|
|
tt.memory,
|
|
NodeMemoryRaw{},
|
|
)
|
|
if !ok {
|
|
t.Fatal("resolveNodeMemory() = ok=false")
|
|
}
|
|
if got := uint64(memory.Used); got != tt.wantUsed {
|
|
t.Fatalf("memory.Used = %d, want %d", got, tt.wantUsed)
|
|
}
|
|
if source != tt.wantSource {
|
|
t.Fatalf("source = %q, want %q", source, tt.wantSource)
|
|
}
|
|
if fallback != tt.wantFallback {
|
|
t.Fatalf("fallback = %q, want %q", fallback, tt.wantFallback)
|
|
}
|
|
if raw.ProxmoxMemorySource != tt.wantRawSource {
|
|
t.Fatalf("raw.ProxmoxMemorySource = %q, want %q", raw.ProxmoxMemorySource, tt.wantRawSource)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestPollPVENodePrefersLinkedHostDiskOverRootFS(t *testing.T) {
|
|
mon := newTestPVEMonitor("test")
|
|
defer mon.alertManager.Stop()
|
|
defer mon.notificationMgr.Stop()
|
|
mon.config.PVEInstances[0].IsCluster = true
|
|
mon.config.PVEInstances[0].ClusterName = "production"
|
|
mon.config.PVEInstances[0].ClusterEndpoints = []config.ClusterEndpoint{{
|
|
NodeIdentity: "production-ebringa",
|
|
NodeName: "ebringa",
|
|
}}
|
|
mon.config.PVEInstances[0].ClusterNodeIdentities = []config.PVEClusterNodeIdentity{{
|
|
ID: "production-ebringa",
|
|
NativeName: "ebringa",
|
|
DisplayName: "Render East",
|
|
}}
|
|
|
|
mon.state.UpsertHost(models.Host{
|
|
ID: "host-1",
|
|
Hostname: "ebringa",
|
|
Status: "online",
|
|
Disks: []models.Disk{
|
|
{Mountpoint: "/", Type: "zfs", Total: 975, Used: 410, Free: 565, Usage: 42.0512820513},
|
|
},
|
|
})
|
|
mon.state.UpdateNodesForInstance("test", []models.Node{
|
|
{
|
|
ID: "production-ebringa",
|
|
NodeIdentity: "production-ebringa",
|
|
Name: "ebringa",
|
|
DisplayName: "Render East",
|
|
Instance: "test",
|
|
LinkedAgentID: "host-1",
|
|
},
|
|
})
|
|
|
|
client := &stubPVEClient{
|
|
nodeStatus: &proxmox.NodeStatus{
|
|
RootFS: &proxmox.RootFS{
|
|
Total: 975,
|
|
Used: 3,
|
|
Free: 972,
|
|
},
|
|
},
|
|
}
|
|
|
|
modelNode, _, _, err := mon.pollPVENode(
|
|
context.Background(),
|
|
"test",
|
|
&mon.config.PVEInstances[0],
|
|
client,
|
|
proxmox.Node{
|
|
Node: "ebringa",
|
|
Status: "online",
|
|
MaxDisk: 975,
|
|
Disk: 3,
|
|
},
|
|
"healthy",
|
|
nil,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("pollPVENode() error = %v", err)
|
|
}
|
|
|
|
if modelNode.Disk.Total != 975 || modelNode.Disk.Used != 410 || modelNode.Disk.Free != 565 {
|
|
t.Fatalf("node disk = %+v, want linked host summary", modelNode.Disk)
|
|
}
|
|
if modelNode.Disk.Usage < 42 || modelNode.Disk.Usage > 42.1 {
|
|
t.Fatalf("node disk usage = %.2f, want linked host usage around 42.05", modelNode.Disk.Usage)
|
|
}
|
|
if modelNode.NodeIdentity != "production-ebringa" ||
|
|
modelNode.Name != "ebringa" ||
|
|
modelNode.DisplayName != "Render East" {
|
|
t.Fatalf("cluster presentation identity corrupted linked-agent lifecycle fields: %+v", modelNode)
|
|
}
|
|
}
|
|
|
|
func TestResolveNodeDiskFallsBackToRootFSWithoutLinkedHost(t *testing.T) {
|
|
mon := newTestPVEMonitor("test")
|
|
defer mon.alertManager.Stop()
|
|
defer mon.notificationMgr.Stop()
|
|
|
|
disk, source := mon.resolveNodeDisk(
|
|
"test",
|
|
"test-node1",
|
|
"node1",
|
|
proxmox.Node{Node: "node1", Disk: 10, MaxDisk: 100},
|
|
&proxmox.NodeStatus{
|
|
RootFS: &proxmox.RootFS{
|
|
Total: 200,
|
|
Used: 50,
|
|
Free: 150,
|
|
},
|
|
},
|
|
)
|
|
|
|
if source != "node-status-rootfs" {
|
|
t.Fatalf("source = %q, want node-status-rootfs", source)
|
|
}
|
|
if disk.Total != 200 || disk.Used != 50 || disk.Free != 150 {
|
|
t.Fatalf("disk = %+v, want rootfs values", disk)
|
|
}
|
|
}
|
|
|
|
func TestPollPVEInstancePreservesNodesEndpointDiskWhenStorageFallbackExists(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
|
|
client := &stubPVEClient{
|
|
nodes: []proxmox.Node{
|
|
{
|
|
Node: "nuc",
|
|
Status: "online",
|
|
CPU: 0.12,
|
|
MaxCPU: 8,
|
|
Mem: 4 * 1024 * 1024 * 1024,
|
|
MaxMem: 8 * 1024 * 1024 * 1024,
|
|
Disk: 92 * 1024 * 1024 * 1024,
|
|
MaxDisk: 916 * 1024 * 1024 * 1024,
|
|
Uptime: 3600,
|
|
},
|
|
},
|
|
allStorage: []proxmox.Storage{{Storage: "local-zfs"}},
|
|
storageByNode: map[string][]proxmox.Storage{
|
|
"nuc": {
|
|
{
|
|
Storage: "local-zfs",
|
|
Type: "zfspool",
|
|
Content: "images,rootdir",
|
|
Shared: 0,
|
|
Total: 944 * 1024 * 1024 * 1024,
|
|
Used: 313 * 1024 * 1024 * 1024,
|
|
Available: 631 * 1024 * 1024 * 1024,
|
|
},
|
|
{
|
|
Storage: "t7shield",
|
|
Type: "dir",
|
|
Content: "backup,iso,vztmpl",
|
|
Shared: 0,
|
|
Total: 916 * 1024 * 1024 * 1024,
|
|
Used: 92 * 1024 * 1024 * 1024,
|
|
Available: 824 * 1024 * 1024 * 1024,
|
|
},
|
|
{
|
|
Storage: "hetzner",
|
|
Type: "dir",
|
|
Content: "backup",
|
|
Shared: 0,
|
|
Total: 711 * 1024 * 1024 * 1024,
|
|
Used: 109 * 1024 * 1024 * 1024,
|
|
Available: 602 * 1024 * 1024 * 1024,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
mon := newTestPVEMonitor("test")
|
|
defer mon.alertManager.Stop()
|
|
defer mon.notificationMgr.Stop()
|
|
mon.config.PVEInstances[0].MonitorStorage = true
|
|
|
|
mon.pollPVEInstance(context.Background(), "test", client)
|
|
|
|
snapshot := mon.state.GetSnapshot()
|
|
if len(snapshot.Nodes) != 1 {
|
|
t.Fatalf("expected one node in state, got %d", len(snapshot.Nodes))
|
|
}
|
|
|
|
node := snapshot.Nodes[0]
|
|
wantTotal := int64(916 * 1024 * 1024 * 1024)
|
|
wantUsed := int64(92 * 1024 * 1024 * 1024)
|
|
wantFree := int64(824 * 1024 * 1024 * 1024)
|
|
if node.Disk.Total != wantTotal || node.Disk.Used != wantUsed || node.Disk.Free != wantFree {
|
|
t.Fatalf("node disk = %+v, want /nodes endpoint totals preserved", node.Disk)
|
|
}
|
|
if node.Disk.Usage < 10.0 || node.Disk.Usage > 10.1 {
|
|
t.Fatalf("node disk usage = %.2f, want /nodes endpoint usage around 10.04", node.Disk.Usage)
|
|
}
|
|
}
|
|
|
|
func TestPollPVEInstanceUsesStorageFallbackWhenNodeDiskUnavailable(t *testing.T) {
|
|
t.Setenv("PULSE_DATA_DIR", t.TempDir())
|
|
|
|
client := &stubPVEClient{
|
|
nodes: []proxmox.Node{
|
|
{
|
|
Node: "nuc",
|
|
Status: "online",
|
|
CPU: 0.12,
|
|
MaxCPU: 8,
|
|
Mem: 4 * 1024 * 1024 * 1024,
|
|
MaxMem: 8 * 1024 * 1024 * 1024,
|
|
Disk: 0,
|
|
MaxDisk: 0,
|
|
Uptime: 3600,
|
|
},
|
|
},
|
|
nodeStatus: nil,
|
|
allStorage: []proxmox.Storage{{Storage: "local-zfs"}},
|
|
storageByNode: map[string][]proxmox.Storage{
|
|
"nuc": {
|
|
{
|
|
Storage: "local-zfs",
|
|
Type: "zfspool",
|
|
Content: "images,rootdir",
|
|
Shared: 0,
|
|
Total: 944 * 1024 * 1024 * 1024,
|
|
Used: 313 * 1024 * 1024 * 1024,
|
|
Available: 631 * 1024 * 1024 * 1024,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
mon := newTestPVEMonitor("test")
|
|
defer mon.alertManager.Stop()
|
|
defer mon.notificationMgr.Stop()
|
|
mon.config.PVEInstances[0].MonitorStorage = true
|
|
|
|
mon.pollPVEInstance(context.Background(), "test", client)
|
|
|
|
snapshot := mon.state.GetSnapshot()
|
|
if len(snapshot.Nodes) != 1 {
|
|
t.Fatalf("expected one node in state, got %d", len(snapshot.Nodes))
|
|
}
|
|
|
|
node := snapshot.Nodes[0]
|
|
wantTotal := int64(944 * 1024 * 1024 * 1024)
|
|
wantUsed := int64(313 * 1024 * 1024 * 1024)
|
|
wantFree := int64(631 * 1024 * 1024 * 1024)
|
|
if node.Disk.Total != wantTotal || node.Disk.Used != wantUsed || node.Disk.Free != wantFree {
|
|
t.Fatalf("node disk = %+v, want local-zfs fallback totals", node.Disk)
|
|
}
|
|
if node.Disk.Usage < 33.1 || node.Disk.Usage > 33.2 {
|
|
t.Fatalf("node disk usage = %.2f, want local-zfs usage around 33.16", node.Disk.Usage)
|
|
}
|
|
}
|