Files
pulse/internal/monitoring/monitor_polling_node.go
T
rcourtman 0c130a4855 Key previous Proxmox nodes by source ID so poll carry-overs match
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.
2026-10-06 19:37:14 +01:00

370 lines
13 KiB
Go

package monitoring
import (
"context"
"sort"
"strconv"
"strings"
"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"
"github.com/rs/zerolog/log"
)
const (
nodeMemoryCarryForwardMaxAge = 2 * time.Minute
pveNodeNetworkRefreshInterval = 5 * time.Minute
)
type pveNodeNetworkGetter interface {
GetNodeNetworkInterfaces(context.Context, string) ([]proxmox.NodeNetworkInterface, error)
}
func previousNodeNetworkInterfaces(previous []models.Node, instance, nodeID, nodeName string) ([]models.HostNetworkInterface, time.Time, bool) {
for _, candidate := range previous {
if strings.TrimSpace(candidate.Instance) != strings.TrimSpace(instance) {
continue
}
if strings.TrimSpace(candidate.ID) == strings.TrimSpace(nodeID) ||
strings.EqualFold(strings.TrimSpace(candidate.Name), strings.TrimSpace(nodeName)) {
interfaces := make([]models.HostNetworkInterface, len(candidate.NetworkInterfaces))
for i, iface := range candidate.NetworkInterfaces {
interfaces[i] = iface
interfaces[i].Addresses = append([]string(nil), iface.Addresses...)
}
return interfaces, candidate.LastSeen, true
}
}
return nil, time.Time{}, false
}
func mapPVENodeNetworkInterfaces(interfaces []proxmox.NodeNetworkInterface) []models.HostNetworkInterface {
mapped := make([]models.HostNetworkInterface, 0, len(interfaces))
for _, iface := range interfaces {
name := strings.TrimSpace(iface.Iface)
if name == "" {
continue
}
addresses := make([]string, 0, 2)
seen := make(map[string]struct{}, 2)
ipv4 := strings.TrimSpace(iface.CIDR)
if ipv4 == "" {
ipv4 = strings.TrimSpace(iface.Address)
}
for _, address := range []string{ipv4, iface.Address6} {
address = strings.TrimSpace(address)
if address == "" {
continue
}
if _, ok := seen[address]; ok {
continue
}
seen[address] = struct{}{}
addresses = append(addresses, address)
}
mapped = append(mapped, models.HostNetworkInterface{Name: name, Addresses: addresses})
}
sort.SliceStable(mapped, func(i, j int) bool {
return strings.ToLower(mapped[i].Name) < strings.ToLower(mapped[j].Name)
})
return mapped
}
func collectPVENodeNetworkInterfaces(
ctx context.Context,
client PVEClientInterface,
instance, nodeID, nodeName string,
previous []models.Node,
) []models.HostNetworkInterface {
fallback, previousPollAt, foundPrevious := previousNodeNetworkInterfaces(previous, instance, nodeID, nodeName)
if foundPrevious && !previousPollAt.IsZero() &&
previousPollAt.Truncate(pveNodeNetworkRefreshInterval) == time.Now().Truncate(pveNodeNetworkRefreshInterval) {
return fallback
}
getter, ok := client.(pveNodeNetworkGetter)
if !ok {
return fallback
}
interfaces, err := getter.GetNodeNetworkInterfaces(ctx, nodeName)
if err != nil {
log.Debug().Err(err).Str("instance", instance).Str("node", nodeName).
Msg("Could not refresh Proxmox node network interfaces; retaining last-known inventory")
return fallback
}
return mapPVENodeNetworkInterfaces(interfaces)
}
// carryForwardNodeMemory returns the node's last memory reading from this
// poller's own snapshot when that reading came from a trusted source recently
// enough to stand in for one failed status read. The unified read state is not
// used here: on a node merged with a host agent it holds the agent's reading,
// not the Proxmox value this snapshot vouches for.
func (m *Monitor) carryForwardNodeMemory(instance, node string, now time.Time) (models.Memory, bool) {
if m == nil {
return models.Memory{}, false
}
m.diagMu.RLock()
snapshot, ok := m.nodeSnapshots[makeNodeSnapshotKey(instance, node)]
m.diagMu.RUnlock()
if !ok || !snapshot.Memory.HasKnownUsage() || snapshot.RetrievedAt.IsZero() {
return models.Memory{}, false
}
age := now.Sub(snapshot.RetrievedAt)
if age < 0 || age > nodeMemoryCarryForwardMaxAge {
return models.Memory{}, false
}
switch CanonicalMemorySource(snapshot.MemorySource) {
case "available-field", "derived-free-buffers-cached", "derived-total-minus-used",
"rrd-memavailable", "rrd-memused":
return snapshot.Memory, true
default:
return models.Memory{}, false
}
}
func (m *Monitor) pollPVENode(
ctx context.Context,
instanceName string,
instanceCfg *config.PVEInstance,
client PVEClientInterface,
node proxmox.Node,
connectionHealthStr string,
prevInstanceNodes []models.Node,
) (models.Node, string, string, error) {
nodeStart := time.Now()
displayName := getNodeDisplayName(instanceCfg, node.Node)
connectionHost, guestURL := resolveNodeConnectionInfo(instanceCfg, monitorDiscoveryConfig(m), node.Node)
nodeID, effectiveStatus := m.determineNodeIDAndStatus(instanceName, instanceCfg, node)
nodeFallbackFree := uint64(0)
if node.MaxMem >= node.Mem {
nodeFallbackFree = node.MaxMem - node.Mem
}
modelNode := models.Node{
ID: nodeID,
NodeIdentity: nodeID,
Name: node.Node,
NativeNameAliases: config.PVEClusterNodeNativeAliases(instanceCfg, node.Node),
DisplayName: displayName,
Instance: instanceName,
Host: connectionHost,
GuestURL: guestURL,
Status: effectiveStatus,
Type: "node",
CPU: safeFloat(node.CPU), // Proxmox returns 0-1 ratio (e.g., 0.15 = 15%)
Memory: models.UnavailableMemory(clampToInt64(node.MaxMem)),
Uptime: int64(node.Uptime),
LoadAverage: []float64{},
LastSeen: time.Now(),
ConnectionHealth: connectionHealthStr, // Use the determined health status
IsClusterMember: instanceCfg.IsCluster,
ClusterName: instanceCfg.ClusterName,
ProviderScopedIdentity: m.pveNodeUsesProviderScopedIdentity(instanceName, instanceCfg),
TLSFingerprint: pveNodeTLSFingerprint(instanceCfg, node.Node),
TemperatureMonitoringEnabled: instanceCfg.TemperatureMonitoringEnabled,
}
var nodeDiskSource string
modelNode.Disk, nodeDiskSource = m.resolveNodeDisk(instanceName, nodeID, node.Node, node, nil)
nodeSnapshotRaw := NodeMemoryRaw{
Total: node.MaxMem,
Used: node.Mem,
Free: nodeFallbackFree,
FallbackTotal: node.MaxMem,
FallbackUsed: node.Mem,
FallbackFree: nodeFallbackFree,
FallbackCalculated: true,
ProxmoxMemorySource: "nodes-endpoint",
}
nodeMemorySource := "unavailable"
nodeFallbackReason := "cache-aware-memory-unavailable"
// Debug logging for disk metrics - note that these values can fluctuate
// due to thin provisioning and dynamic allocation
if node.Disk > 0 && node.MaxDisk > 0 {
log.Debug().
Str("node", node.Node).
Uint64("disk", node.Disk).
Uint64("maxDisk", node.MaxDisk).
Float64("diskUsage", safePercentage(float64(node.Disk), float64(node.MaxDisk))).
Msg("Node disk metrics from /nodes endpoint")
}
// Track whether we successfully replaced memory metrics with detailed status data
memoryUpdated := false
// Get detailed node info if available (skip for offline nodes)
if effectiveStatus == "online" {
nodeInfo, nodeErr := client.GetNodeStatus(ctx, node.Node)
if nodeErr != nil {
nodeFallbackReason = "node-status-unavailable"
// If we can't get node status, log but continue with data from /nodes endpoint
if node.Disk > 0 && node.MaxDisk > 0 {
log.Warn().
Str("instance", instanceName).
Str("node", node.Node).
Err(nodeErr).
Uint64("usingDisk", node.Disk).
Uint64("usingMaxDisk", node.MaxDisk).
Msg("Could not get node status - disk fallback retained; memory usage unavailable")
} else {
log.Warn().
Str("instance", instanceName).
Str("node", node.Node).
Err(nodeErr).
Uint64("disk", node.Disk).
Uint64("maxDisk", node.MaxDisk).
Msg("Could not get node status - memory usage unavailable")
}
} else if nodeInfo != nil {
if nodeInfo.Memory != nil {
nodeSnapshotRaw.Total = nodeInfo.Memory.Total
nodeSnapshotRaw.Used = nodeInfo.Memory.Used
nodeSnapshotRaw.Free = nodeInfo.Memory.Free
nodeSnapshotRaw.Available = nodeInfo.Memory.Available
nodeSnapshotRaw.Avail = nodeInfo.Memory.Avail
nodeSnapshotRaw.Buffers = nodeInfo.Memory.Buffers
nodeSnapshotRaw.Cached = nodeInfo.Memory.Cached
nodeSnapshotRaw.Shared = nodeInfo.Memory.Shared
nodeSnapshotRaw.EffectiveAvailable = nodeInfo.Memory.EffectiveAvailable()
nodeSnapshotRaw.ProxmoxMemorySource = "node-status"
nodeSnapshotRaw.FallbackCalculated = false
}
// Convert LoadAvg from interface{} to float64
loadAvg := make([]float64, 0, len(nodeInfo.LoadAvg))
for _, val := range nodeInfo.LoadAvg {
switch v := val.(type) {
case float64:
loadAvg = append(loadAvg, v)
case string:
if f, err := strconv.ParseFloat(v, 64); err == nil {
loadAvg = append(loadAvg, f)
}
}
}
modelNode.LoadAverage = loadAvg
modelNode.KernelVersion = nodeInfo.KernelVersion
modelNode.PVEVersion = nodeInfo.PVEVersion
if resolvedDisk, diskSource := m.resolveNodeDisk(instanceName, nodeID, node.Node, node, nodeInfo); diskSource != "" {
modelNode.Disk = resolvedDisk
nodeDiskSource = diskSource
} else {
log.Warn().
Str("node", node.Node).
Bool("rootfsNil", nodeInfo.RootFS == nil).
Uint64("nodeDisk", node.Disk).
Uint64("nodeMaxDisk", node.MaxDisk).
Msg("No valid disk metrics available for node")
}
// Update memory metrics to use Available field for more accurate usage
if nodeInfo.Memory != nil && nodeInfo.Memory.Total > 0 {
resolvedMemory, resolvedSource, resolvedFallback, resolvedRaw, ok := m.resolveNodeMemory(
ctx,
client,
instanceName,
node.Node,
nodeInfo.Memory,
nodeSnapshotRaw,
)
if ok {
modelNode.Memory = resolvedMemory
nodeMemorySource = resolvedSource
nodeFallbackReason = resolvedFallback
nodeSnapshotRaw = resolvedRaw
memoryUpdated = resolvedMemory.HasKnownUsage()
}
}
if nodeInfo.CPUInfo != nil {
// Use MaxCPU from node data for logical CPU count (includes hyperthreading)
// If MaxCPU is not available or 0, fall back to physical cores
logicalCores := node.MaxCPU
if logicalCores == 0 {
logicalCores = nodeInfo.CPUInfo.Cores
}
mhzStr := nodeInfo.CPUInfo.GetMHzString()
log.Debug().
Str("node", node.Node).
Str("model", nodeInfo.CPUInfo.Model).
Int("cores", nodeInfo.CPUInfo.Cores).
Int("logicalCores", logicalCores).
Int("sockets", nodeInfo.CPUInfo.Sockets).
Str("mhz", mhzStr).
Msg("Node CPU info from Proxmox")
modelNode.CPUInfo = models.CPUInfo{
Model: nodeInfo.CPUInfo.Model,
Cores: logicalCores, // Use logical cores for display
Sockets: nodeInfo.CPUInfo.Sockets,
MHz: mhzStr,
}
}
}
}
if effectiveStatus == "online" {
modelNode.NetworkInterfaces = collectPVENodeNetworkInterfaces(
ctx, client, instanceName, nodeID, node.Node, prevInstanceNodes,
)
} else {
modelNode.NetworkInterfaces, _, _ = previousNodeNetworkInterfaces(
prevInstanceNodes, instanceName, nodeID, node.Node,
)
}
// If we couldn't update memory metrics using detailed status, preserve previous accurate values if available
if !memoryUpdated && effectiveStatus == "online" {
if prevMem, ok := m.carryForwardNodeMemory(instanceName, node.Node, time.Now()); ok {
total := int64(node.MaxMem)
if total == 0 {
total = prevMem.Total
}
used := prevMem.Used
if total > 0 && used > total {
used = total
}
// Keep the snapshot's used | cache | free split summing to total.
cache := min(max(prevMem.Cache, 0), max(total-used, 0))
free := max(total-used-cache, 0)
preserved := prevMem
preserved.Total = total
preserved.Used = used
preserved.Cache = cache
preserved.Free = free
preserved.Usage = safePercentage(float64(used), float64(total))
modelNode.Memory = preserved
log.Debug().
Str("instance", instanceName).
Str("node", node.Node).
Msg("Preserving previous memory metrics - node status unavailable this cycle")
nodeFallbackReason = "preserved-previous-snapshot"
nodeMemorySource = "previous-snapshot"
if nodeSnapshotRaw.ProxmoxMemorySource == "node-status" && nodeSnapshotRaw.Total == 0 {
nodeSnapshotRaw.ProxmoxMemorySource = "previous-snapshot"
}
}
}
m.recordNodeSnapshot(instanceName, node.Node, NodeMemorySnapshot{
RetrievedAt: time.Now(),
MemorySource: nodeMemorySource,
FallbackReason: nodeFallbackReason,
Memory: modelNode.Memory,
Raw: nodeSnapshotRaw,
})
m.collectNodeTemperatureData(ctx, instanceName, instanceCfg, node, &modelNode, prevInstanceNodes, effectiveStatus)
m.applyNodePendingUpdates(ctx, instanceName, client, node, nodeID, effectiveStatus, &modelNode)
m.recordNodePollMetrics(instanceName, node, &modelNode, nodeStart)
return modelNode, effectiveStatus, nodeDiskSource, nil
}