From 1a78dcbba29df1c25da1f9f699ce376189b2b1a7 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 6 Nov 2025 18:42:46 +0000 Subject: [PATCH] Fix guest agent disk data regression on Proxmox 8.3+ Related to #630 Proxmox 8.3+ changed the VM status API to return the `agent` field as an object ({"enabled":1,"available":1}) instead of an integer (0 or 1). This caused Pulse to incorrectly treat VMs as having no guest agent, resulting in missing disk usage data (disk:-1) even when the guest agent was running and functional. The issue manifested as: - VMs showing "Guest details unavailable" or missing disk data - Pulse logs showing no "Guest agent enabled, querying filesystem info" messages - `pvesh get /nodes//qemu//agent/get-fsinfo` working correctly from the command line, confirming the agent was functional Root cause: The VMStatus struct defined `Agent` as an int field. When Proxmox 8.3+ sent the new object format, JSON unmarshaling silently left the field at zero, causing Pulse to skip all guest agent queries. Changes: - Created VMAgentField type with custom UnmarshalJSON to handle both formats: * Legacy (Proxmox <8.3): integer (0 or 1) * Modern (Proxmox 8.3+): object {"enabled":N,"available":N} - Updated VMStatus.Agent from `int` to `VMAgentField` - Updated all references to `detailedStatus.Agent` to use `.Agent.Value` - The unmarshaler prioritizes the "available" field over "enabled" to ensure we only query when the agent is actually responding This fix maintains backward compatibility with older Proxmox versions while supporting the new format introduced in Proxmox 8.3+. --- internal/api/diagnostics.go | 4 +- internal/monitoring/monitor.go | 10 ++-- internal/monitoring/monitor_polling.go | 8 +-- pkg/proxmox/client.go | 73 ++++++++++++++++++++------ 4 files changed, 67 insertions(+), 28 deletions(-) diff --git a/internal/api/diagnostics.go b/internal/api/diagnostics.go index 1fe6322..4111014 100644 --- a/internal/api/diagnostics.go +++ b/internal/api/diagnostics.go @@ -1260,7 +1260,7 @@ func (r *Router) checkVMDiskMonitoring(ctx context.Context, client *proxmox.Clie Status: vm.Status, Issue: "Failed to get VM status: " + errStr, }) - } else if vmStatus != nil && vmStatus.Agent > 0 { + } else if vmStatus != nil && vmStatus.Agent.Value > 0 { result.VMsWithAgent++ // Try to get filesystem info @@ -1359,7 +1359,7 @@ func (r *Router) checkVMDiskMonitoring(ctx context.Context, client *proxmox.Clie "Verify the node is reachable and API token is valid", ) } - } else if vmStatus == nil || vmStatus.Agent == 0 { + } else if vmStatus == nil || vmStatus.Agent.Value == 0 { result.TestResult = "Guest agent not enabled in VM configuration" result.Recommendations = append(result.Recommendations, "Enable QEMU Guest Agent in VM Options", diff --git a/internal/monitoring/monitor.go b/internal/monitoring/monitor.go index 055b33b..d6c07df 100644 --- a/internal/monitoring/monitor.go +++ b/internal/monitoring/monitor.go @@ -2113,7 +2113,7 @@ func (m *Monitor) fetchGuestAgentMetadata(ctx context.Context, client PVEClientI return nil, nil, "", "", "" } - if vmStatus.Agent <= 0 { + if vmStatus.Agent.Value <= 0 { m.clearGuestMetadataCache(instanceName, nodeName, vmid) return nil, nil, "", "", "" } @@ -6069,7 +6069,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam guestRaw.StatusFreeMem = detailedStatus.FreeMem guestRaw.Balloon = detailedStatus.Balloon guestRaw.BalloonMin = detailedStatus.BalloonMin - guestRaw.Agent = detailedStatus.Agent + guestRaw.Agent = detailedStatus.Agent.Value memAvailable := uint64(0) if detailedStatus.MemInfo != nil { guestRaw.MemInfoUsed = detailedStatus.MemInfo.Used @@ -6145,12 +6145,12 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam // Always try to get filesystem info if agent is enabled // Prefer guest agent data over cluster/resources data for accuracy - if detailedStatus.Agent > 0 { + if detailedStatus.Agent.Value > 0 { log.Debug(). Str("instance", instanceName). Str("vm", res.Name). Int("vmid", res.VMID). - Int("agent", detailedStatus.Agent). + Int("agent", detailedStatus.Agent.Value). Uint64("current_disk", diskUsed). Uint64("current_maxdisk", diskTotal). Msg("Guest agent enabled, querying filesystem info for accurate disk usage") @@ -6425,7 +6425,7 @@ func (m *Monitor) pollVMsAndContainersEfficient(ctx context.Context, instanceNam Str("instance", instanceName). Str("vm", res.Name). Int("vmid", res.VMID). - Int("agent", detailedStatus.Agent). + Int("agent", detailedStatus.Agent.Value). Msg("VM does not have guest agent enabled in config") } } else { diff --git a/internal/monitoring/monitor_polling.go b/internal/monitoring/monitor_polling.go index 8e347c4..b7fc17b 100644 --- a/internal/monitoring/monitor_polling.go +++ b/internal/monitoring/monitor_polling.go @@ -288,7 +288,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli guestRaw.StatusFreeMem = status.FreeMem guestRaw.Balloon = status.Balloon guestRaw.BalloonMin = status.BalloonMin - guestRaw.Agent = status.Agent + guestRaw.Agent = status.Agent.Value memAvailable := uint64(0) if status.MemInfo != nil { guestRaw.MemInfoUsed = status.MemInfo.Used @@ -465,14 +465,14 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli Str("instance", instanceName). Str("vm", vm.Name). Int("vmid", vm.VMID). - Int("agent", vmStatus.Agent). + Int("agent", vmStatus.Agent.Value). Uint64("diskUsed", diskUsed). Uint64("diskTotal", diskTotal). Msg("VM has 0 disk usage, checking guest agent") } // Check if agent is enabled - if vmStatus.Agent == 0 { + if vmStatus.Agent.Value == 0 { diskStatusReason = "agent-disabled" if logging.IsLevelEnabled(zerolog.DebugLevel) { log.Debug(). @@ -480,7 +480,7 @@ func (m *Monitor) pollVMsWithNodes(ctx context.Context, instanceName string, cli Str("vm", vm.Name). Msg("Guest agent disabled in VM config") } - } else if vmStatus.Agent > 0 || diskUsed == 0 { + } else if vmStatus.Agent.Value > 0 || diskUsed == 0 { if logging.IsLevelEnabled(zerolog.DebugLevel) { log.Debug(). Str("instance", instanceName). diff --git a/pkg/proxmox/client.go b/pkg/proxmox/client.go index 306e9bf..307784c 100644 --- a/pkg/proxmox/client.go +++ b/pkg/proxmox/client.go @@ -1780,25 +1780,64 @@ type VMMemInfo struct { Shared uint64 `json:"shared,omitempty"` } +// VMAgentField handles the polymorphic agent field that changed in Proxmox 8.3+. +// Older versions: integer (0 or 1) +// Proxmox 8.3+: object {"enabled":1,"available":1} or similar +type VMAgentField struct { + Value int +} + +// UnmarshalJSON implements custom JSON unmarshaling to handle both int and object formats +func (a *VMAgentField) UnmarshalJSON(data []byte) error { + // Try parsing as int first (older Proxmox versions) + var intValue int + if err := json.Unmarshal(data, &intValue); err == nil { + a.Value = intValue + return nil + } + + // Try parsing as object (Proxmox 8.3+) + var objValue struct { + Enabled int `json:"enabled"` + Available int `json:"available"` + } + if err := json.Unmarshal(data, &objValue); err == nil { + // Agent is considered enabled if either field is > 0 + // Typically we want to check "available" for actual functionality + if objValue.Available > 0 { + a.Value = objValue.Available + } else if objValue.Enabled > 0 { + a.Value = objValue.Enabled + } else { + a.Value = 0 + } + return nil + } + + // If neither worked, default to 0 (agent disabled) + a.Value = 0 + return nil +} + // VMStatus represents detailed VM status returned by Proxmox. type VMStatus struct { - Status string `json:"status"` - CPU float64 `json:"cpu"` - CPUs int `json:"cpus"` - Mem uint64 `json:"mem"` - MaxMem uint64 `json:"maxmem"` - Balloon uint64 `json:"balloon"` - BalloonMin uint64 `json:"balloon_min"` - FreeMem uint64 `json:"freemem"` - MemInfo *VMMemInfo `json:"meminfo,omitempty"` - Disk uint64 `json:"disk"` - MaxDisk uint64 `json:"maxdisk"` - DiskRead uint64 `json:"diskread"` - DiskWrite uint64 `json:"diskwrite"` - NetIn uint64 `json:"netin"` - NetOut uint64 `json:"netout"` - Uptime uint64 `json:"uptime"` - Agent int `json:"agent"` + Status string `json:"status"` + CPU float64 `json:"cpu"` + CPUs int `json:"cpus"` + Mem uint64 `json:"mem"` + MaxMem uint64 `json:"maxmem"` + Balloon uint64 `json:"balloon"` + BalloonMin uint64 `json:"balloon_min"` + FreeMem uint64 `json:"freemem"` + MemInfo *VMMemInfo `json:"meminfo,omitempty"` + Disk uint64 `json:"disk"` + MaxDisk uint64 `json:"maxdisk"` + DiskRead uint64 `json:"diskread"` + DiskWrite uint64 `json:"diskwrite"` + NetIn uint64 `json:"netin"` + NetOut uint64 `json:"netout"` + Uptime uint64 `json:"uptime"` + Agent VMAgentField `json:"agent"` } // GetZFSPoolStatus gets the status of ZFS pools on a node