Read a linked agent's own heartbeat in host snapshots (#2601)

A host agent linked to a Proxmox node merges into one unified host row.
When the agent stops reporting but PVE polling continues, that row stays
online and its `LastSeen` follows the PVE sighting.
`hostFromReadStateView` copied both into `models.Host`, so a silent
agent looked current to every consumer of the projection:

- `linkedHostForNode` accepted it, and `resolveNodeDisk` kept using the
agent's last filesystem summary as the node's disk, feeding disk alerts
and history with a frozen reading. Its retained ZFS datasets kept
merging into the node's pools.
- Agent connections built from `HostsSnapshot()` (the Connections list
and the update-readiness agent check) aged the agent from the PVE poll,
so a dead agent stayed Active.

The projection now takes `LastSeen` from the agent source's own sighting
(`HostView.SourceStatus(SourceAgent)`, added in #2582) and reports the
agent offline once that sighting is past its reporting lease
(`hostAgentReportCurrent`, the same `hostAgentHealthWindow` that
`evaluateHostAgents` applies). Rows without an agent sighting, such as
vSphere and TrueNAS hosts, keep the row's values.

Verification:
- New `TestSilentLinkedAgentStopsFeedingNodeDisk` (registry-backed
merged agent + node row, driven through `evaluateHostAgents` and the
store refresh): node disk uses the agent summary while it reports, falls
back to the Proxmox rootfs past the lease, and returns when the agent
reports again; the projected agent reads offline with its own last
report. It fails against the previous `monitor.go` (checked with `go
test -overlay`).
- `go test ./internal/monitoring/` and the connections, readiness,
update and agent-lifecycle tests in `./internal/api/` pass.
This commit is contained in:
rcourtman
2026-10-06 20:02:09 +01:00
committed by GitHub
4 changed files with 122 additions and 2 deletions
@@ -8807,3 +8807,15 @@ no update status, instead of reporting "no update available" (#2353). Agent
registration, enrolment, install, update, removal and report identity are
unchanged; the per-image memo lives in the registry checker and is pruned
each collection cycle to the images in use.
### Host snapshots report a linked agent's own heartbeat
`internal/monitoring/monitor.go` changed only so a host produced from the read
state carries the agent source's own last report and reads offline past the
agent's reporting lease, even when the agent's row is merged with a Proxmox
node that PVE polling keeps fresh. The agent connection on the Connections
list and the update-readiness agent-continuity check therefore age a silent
agent from its last report instead of from the PVE poll, and mark it stale at
their own heartbeat cutoff (`fleethealth.AgentStaleThreshold`, which is
separate from the monitoring reporting lease). Agent registration, enrolment,
install, update, removal and report identity are unchanged.
@@ -2638,6 +2638,25 @@ online/running/healthy vocabulary. The diagnostics subject set is unchanged:
snapshot (real agents), which now matches what agent-only surfaces show once
integration-backed ledger rows are excluded.
A host produced by `hostFromReadStateView` describes the agent, so its
`Status` and `LastSeen` are the agent's own. A host agent linked to a Proxmox
node merges into one row whose `LastSeen` follows its freshest source and
whose status stays online while PVE polling continues, so the row's values
would present a silent agent as online and current. The projection reads the
agent source's sighting from `HostView.SourceStatus(SourceAgent)`: the host
carries that report time and reads `offline` once it is past the reporting
lease (`hostAgentReportCurrent`, the `hostAgentHealthWindow` that
`evaluateHostAgents` applies to the host in state). Rows without an agent
sighting, such as vSphere and TrueNAS integration hosts, keep the row's
values. `linkedHostForNode` therefore stops handing a silent agent's retained
filesystem summary and ZFS datasets to its node, whose disk falls back to the
Proxmox rootfs or `/nodes` reading before disk alerts and history read it, and
agent connections built from `HostsSnapshot()` (the Connections list and the
update-readiness agent-continuity check) age the agent from its last report
rather than the PVE poll time, applying their own heartbeat cutoff.
`TestSilentLinkedAgentStopsFeedingNodeDisk` drives the disk and projection
path through `evaluateHostAgents` and the registry-backed store refresh.
Unified Agent host reports now make module readiness and updater/config
lifecycle evidence monitoring-owned observed state. Monitoring preserves the
last successful one-shot update transition across subsequent reports, forwards
+22 -2
View File
@@ -3616,6 +3616,7 @@ func hostFromReadStateView(view *unifiedresources.HostView) models.Host {
if trimmed := strings.TrimSpace(view.Name()); trimmed != "" && trimmed != view.Hostname() {
displayName = trimmed
}
status, lastSeen := hostAgentLivenessFromReadStateView(view, time.Now())
return models.Host{
ID: firstNonEmptyString(view.AgentID(), view.ID()),
@@ -3637,10 +3638,10 @@ func hostFromReadStateView(view *unifiedresources.HostView) models.Host {
RAID: hostRAIDFromReadStateView(view.RAID()),
Unraid: hostUnraidFromReadStateView(view.Unraid()),
Ceph: hostCephFromReadStateView(view.Ceph()),
Status: string(view.Status()),
Status: status,
UptimeSeconds: view.UptimeSeconds(),
IntervalSeconds: view.IntervalSeconds(),
LastSeen: view.LastSeen(),
LastSeen: lastSeen,
AgentVersion: view.AgentVersion(),
IntegrationSource: view.IntegrationSource(),
MachineID: view.MachineID(),
@@ -3663,6 +3664,25 @@ func hostFromReadStateView(view *unifiedresources.HostView) models.Host {
}
}
// hostAgentLivenessFromReadStateView returns the status and last report of the
// agent behind a read-state host row. A row merged with a Proxmox node takes
// LastSeen from its freshest source and stays online while PVE polling
// continues, so a silent agent would look current: its retained disk summary
// and datasets kept feeding the node, and its connection read as active. A
// models.Host describes the agent, so read the agent source's own sighting
// and lease, as evaluateHostAgents does for the host in state.
func hostAgentLivenessFromReadStateView(view *unifiedresources.HostView, now time.Time) (string, time.Time) {
status, lastSeen := string(view.Status()), view.LastSeen()
agentSighting, ok := view.SourceStatus(unifiedresources.SourceAgent)
if !ok || agentSighting.LastSeen.IsZero() {
return status, lastSeen
}
if !hostAgentReportCurrent(agentSighting.LastSeen, view.IntervalSeconds(), now) {
status = "offline"
}
return status, agentSighting.LastSeen
}
func vmFromReadStateView(view *unifiedresources.VMView) models.VM {
if view == nil {
return models.VM{}
@@ -24,6 +24,7 @@ import (
agentsdocker "github.com/rcourtman/pulse-go-rewrite/pkg/agents/docker"
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
"github.com/rcourtman/pulse-go-rewrite/pkg/metrics"
"github.com/rcourtman/pulse-go-rewrite/pkg/proxmox"
)
func TestNewMonitorRoutesStartupCustomSensorWarningBeforeStart(t *testing.T) {
@@ -6843,3 +6844,71 @@ func TestBroadcastProjectionListsRegistryOnceAndKeepsLiveChanges(t *testing.T) {
t.Fatal("removed host lifecycle surface disappeared")
}
}
// A host agent linked to a Proxmox node merges into one read-state row that
// every PVE poll keeps online and fresh. Once the agent stops reporting, its
// retained filesystem summary must stop standing in for the node's disk (and
// through it disk alerts and history), and the projected agent must read as
// offline with its own last report, so its connection shows the missed
// heartbeat instead of the PVE poll time.
func TestSilentLinkedAgentStopsFeedingNodeDisk(t *testing.T) {
state := models.NewState()
adapter := unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil))
m := &Monitor{config: &config.Config{}, state: state, resourceStore: adapter}
node := models.Node{
ID: "pve1-node1", Name: "node1", Instance: "pve1", Status: "online",
ConnectionHealth: "healthy", LinkedAgentID: "agent-1",
}
rootFS := &proxmox.NodeStatus{RootFS: &proxmox.RootFS{Total: 200, Used: 50, Free: 150}}
// poll records a PVE poll that just saw the node, with the agent as of its
// last report, and resolves the node's disk the way pollPVENode does.
poll := func(agentLastReport time.Time) (models.Disk, string, models.Host) {
t.Helper()
polledNode := node
polledNode.LastSeen = time.Now()
state.UpdateNodes([]models.Node{polledNode})
state.UpsertHost(models.Host{
ID: "agent-1", Hostname: "node1", Status: "online", LinkedNodeID: node.ID,
IntervalSeconds: 30, LastSeen: agentLastReport,
Disks: []models.Disk{{Mountpoint: "/", Type: "ext4", Total: 1000, Used: 900, Free: 100, Usage: 90}},
})
m.evaluateHostAgents(time.Now())
m.refreshUnifiedResourceStoreAfterAgentStateChange()
disk, source := m.resolveNodeDisk("pve1", node.ID, node.Name, proxmox.Node{Node: node.Name}, rootFS)
for _, host := range m.HostsSnapshot() {
if host.ID == "agent-1" {
return disk, source, host
}
}
t.Fatal("HostsSnapshot does not list agent-1")
return disk, source, models.Host{}
}
agentReport := time.Now().Add(-10 * time.Second)
disk, source, host := poll(agentReport)
if source != "agent" || disk.Used != 900 {
t.Fatalf("reporting agent: node disk = %+v from %q, want the agent's summary", disk, source)
}
if host.Status != "online" || !host.LastSeen.Equal(agentReport) {
t.Fatalf("reporting agent projected as %q last seen %s, want online at its report %s", host.Status, host.LastSeen, agentReport)
}
silentSince := time.Now().Add(-hostAgentHealthWindow(30) - time.Minute)
disk, source, host = poll(silentSince)
if source != "node-status-rootfs" || disk.Used != 50 {
t.Fatalf("silent agent: node disk = %+v from %q, want Proxmox rootfs", disk, source)
}
if linked := m.linkedHostForNode("pve1", node.ID, node.Name); linked != nil {
t.Fatalf("silent agent still linked to the node for disk and dataset data: %+v", linked)
}
if host.Status != "offline" || !host.LastSeen.Equal(silentSince) {
t.Fatalf("silent agent projected as %q last seen %s, want offline at its last report %s", host.Status, host.LastSeen, silentSince)
}
disk, source, host = poll(time.Now())
if source != "agent" || disk.Used != 900 || host.Status != "online" {
t.Fatalf("agent reporting again: node disk = %+v from %q, agent %q; want the agent's summary and online", disk, source, host.Status)
}
}