Integrate reviewed PBS guest Agent correlation

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-09-29 23:40:59 +01:00
5 changed files with 161 additions and 5 deletions
@@ -315,6 +315,8 @@ The same agent can still arrive as two distinct resources: a PVE guest carrying
For token-auth PBS connections that cannot report `nodeName`, a registry-corroborated `pbs.linkedAgentId` takes precedence over connection labels when selecting the Agent-bearing host row. It must resolve to the same source-native Agent ID on a standalone or PVE-merged host; a PVE-only node with no Agent facet cannot supply host History. The PBS row and service target stay canonical; only its drawer presentation uses the host metrics target. If the explicit link changes, retained correlation to the former host is discarded rather than carrying old charts across a replacement.
The backend may establish that explicit link through a running PVE guest's unique, fresh endpoint IP and its state-owned Agent/VM link when the in-guest Agent omits interface IPs. It declines reused/non-unique IPs, stopped or stale guests, and competing Agent links. No connection-name guess or VirtualBox host inference is implied by this fallback; when it cannot identify a host, only the separately observed PBS API CPU/memory service History is available.
A realtime refresh can briefly omit the correlated host row while the PBS server row remains. Retain the last resolved host per PBS server across that omission instead of falling back to the service target, so the drawer's Identity rows and History series stay on the host key. Reuse the remembered host only while it is still fresh relative to the server and drop it once stale, so a removed or replaced host is not advertised indefinitely; a present but ambiguous host still declines.
Verification: ProxmoxBackupServersTable.drawer.test.tsx covers standalone and
@@ -508,6 +508,8 @@ PBS presentation correlation accepts standalone agents and VM/system-container r
When the registry uniquely corroborates a PBS connection to a host agent by node name, connection name, endpoint hostname or reported interface IP, the PBS service may expose that source-native agent ID as `pbs.linkedAgentId`. This is a presentation link, not a replacement for the PBS service's canonical ID or metrics target. Token-auth connections may lack `nodeName`; consumers must use the explicit link in preference to display-name matching and must not infer one from an IP or name themselves. PVE-only nodes without an actual Agent facet are not host-history candidates, even if their name matches the PBS connection. A changed link invalidates any remembered correlation to an old host.
For a PBS inside a PVE VM whose in-guest Agent omits interface IPs, the registry may also corroborate a unique Agent-to-VM state link with a unique running VM reporting the PBS endpoint's exact IP. All three observations must be within five minutes, and reused/non-unique IPs, stopped guests or competing Agent links decline to associate. This is an alternate backend evidence chain, not frontend hostname inference; it cannot identify a VirtualBox guest without equivalent observed topology.
A single agent surfaced as both a VM/system-container guest and a standalone agent row is one identity, not an ambiguous pair. Collapse candidates that share an agent identity before deciding, and prefer the guest's canonical metricsTarget because it names the persisted host series; the PBS service target names the service key and now records only PBS API node-status CPU/memory history, not host-agent disk, network, or I/O. Only distinct agent identities, or candidates whose identity cannot be proven equal, decline to choose. Never use input order or a fallback that discards the PBS row id.
A realtime refresh can briefly omit the correlated host row while the PBS server row remains. Retain the last resolved correlation per PBS server across that omission rather than substituting the PBS service target, so the drawer's identity rows and history target stay stable. Reuse the remembered host only while it is still fresh relative to the server and drop it once stale, so a removed or replaced host is not advertised indefinitely; a present but ambiguous host still declines.
@@ -5170,8 +5172,8 @@ and other typed physical-disk facts therefore remain agent-owned, while the
PBS source mapping and PBS-parent relationship make the existing disk
discoverable in Proxmox Backup Server storage context. Canonical parent and
fact precedence continue to follow the existing source-priority rules.
`internal/unifiedresources/pbs_pmg_registry_test.go` pins the unique-match and
ambiguous-match boundaries.
`internal/unifiedresources/pbs_pmg_registry_test.go` pins the unique-match,
guest-IP fallback, stale-observation and ambiguous-match boundaries.
That same shared-consumer boundary now also owns VMware phase-1 detail
presentation. `frontend-modern/src/components/Infrastructure/`
`resourceDetailDrawerVmwareModel.ts`,
+59 -2
View File
@@ -3,6 +3,7 @@ package unifiedresources
import (
"fmt"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
)
@@ -15,8 +16,9 @@ import (
func (rr *ResourceRegistry) associatePBSHostAgentResources(
instance models.PBSInstance,
hosts []models.Host,
vms []models.VM,
) {
host := uniquePBSHostAgent(instance, hosts)
host := uniquePBSHostAgent(instance, hosts, vms)
if host == nil {
return
}
@@ -89,7 +91,7 @@ func (rr *ResourceRegistry) associatePBSHostAgentResources(
rr.viewsDirty = true
}
func uniquePBSHostAgent(instance models.PBSInstance, hosts []models.Host) *models.Host {
func uniquePBSHostAgent(instance models.PBSInstance, hosts []models.Host, vms []models.VM) *models.Host {
var match *models.Host
for index := range hosts {
if !pbsInstanceCorroboratesHost(instance, hosts[index]) {
@@ -100,9 +102,64 @@ func uniquePBSHostAgent(instance models.PBSInstance, hosts []models.Host) *model
}
match = &hosts[index]
}
if match != nil {
return match
}
// A PBS inside a PVE VM may have an API endpoint IP that the in-guest
// Pulse Agent does not report as an interface. A state-linked Agent and a
// PVE guest observed at that exact IP form a safe alternate chain. Names
// alone do not: a PBS connection label is not host identity. Reject reused
// guest IPs and multiple agents linked to one guest rather than selecting
// an arbitrary host.
endpointIP := NormalizeIP(extractHostname(instance.Host))
if isNonUniqueIP(endpointIP) {
return nil
}
guestID := ""
for _, vm := range vms {
if !strings.EqualFold(strings.TrimSpace(vm.Status), "running") ||
!pbsGuestLinkObservationFresh(instance.LastSeen, vm.LastSeen) {
continue
}
for _, address := range vm.IPAddresses {
if NormalizeIP(address) != endpointIP {
continue
}
id := strings.TrimSpace(vm.ID)
if id == "" || guestID != "" && guestID != id {
return nil
}
guestID = id
break
}
}
if guestID == "" {
return nil
}
for index := range hosts {
if strings.TrimSpace(hosts[index].LinkedVMID) != guestID {
continue
}
if !pbsGuestLinkObservationFresh(instance.LastSeen, hosts[index].LastSeen) {
continue
}
if match != nil {
return nil
}
match = &hosts[index]
}
return match
}
func pbsGuestLinkObservationFresh(pbsSeen, peerSeen time.Time) bool {
if pbsSeen.IsZero() || peerSeen.IsZero() {
return false
}
delta := pbsSeen.Sub(peerSeen)
return delta >= -5*time.Minute && delta <= 5*time.Minute
}
func pbsInstanceCorroboratesHost(instance models.PBSInstance, host models.Host) bool {
hostName := NormalizeHostname(host.Hostname)
if hostName == "" {
@@ -428,6 +428,101 @@ func TestIngestSnapshotPBSHostLinkSurvivesSideBySidePVEWithToken(t *testing.T) {
}
}
// #1723 includes PBS running inside PVE guests. A token-auth PBS connection
// cannot supply nodeName, and an in-guest Agent may omit interface addresses;
// the independently observed PVE guest IP plus the state-owned Agent/VM link
// still identify the machine without guessing from connection labels.
func TestIngestSnapshotAssociatesPBSGuestAgentByExactVMIP(t *testing.T) {
now := time.Now().UTC()
snapshot := models.StateSnapshot{
Hosts: []models.Host{
{ID: "agent-one", Hostname: "guest-one", LinkedVMID: "pve:node:103", Status: "online", LastSeen: now},
{ID: "agent-two", Hostname: "guest-two", LinkedVMID: "pve:node:203", Status: "online", LastSeen: now},
},
VMs: []models.VM{
{ID: "pve:node:103", VMID: 103, Name: "pbs-vm-one", Instance: "pve", Node: "node", Status: "running", IPAddresses: []string{"10.2.0.13"}, LastSeen: now},
{ID: "pve:node:203", VMID: 203, Name: "pbs-vm-two", Instance: "pve", Node: "node", Status: "running", IPAddresses: []string{"10.2.0.23"}, LastSeen: now},
},
PBSInstances: []models.PBSInstance{
{ID: "pbs-one", Name: "backup-one", Host: "https://10.2.0.13:8007", Status: "online", LastSeen: now},
{ID: "pbs-two", Name: "backup-two", Host: "https://10.2.0.23:8007", Status: "online", LastSeen: now},
},
}
registry := NewRegistry(nil)
registry.IngestSnapshot(snapshot)
links := map[string]string{}
linkedHosts := map[string]bool{}
for _, resource := range registry.ListForPresentation() {
if resource.Type == ResourceTypePBS && resource.PBS != nil {
links[resource.PBS.InstanceID] = resource.PBS.LinkedAgentID
}
if resource.Type == ResourceTypeAgent && resource.Agent != nil && containsDataSource(resource.Sources, SourcePBS) {
linkedHosts[resource.Agent.AgentID] = true
target := registry.MetricsTarget(resource.ID)
if target == nil || *target != (MetricsTarget{ResourceType: "agent", ResourceID: resource.Agent.AgentID}) {
t.Fatalf("host %s history target = %+v", resource.Agent.AgentID, target)
}
}
}
if links["pbs-one"] != "agent-one" || links["pbs-two"] != "agent-two" ||
!linkedHosts["agent-one"] || !linkedHosts["agent-two"] {
t.Fatalf("guest PBS links = %v, PBS-source hosts = %v", links, linkedHosts)
}
}
func TestIngestSnapshotPBSGuestIPAssociationFailsClosed(t *testing.T) {
now := time.Now().UTC()
base := func() models.StateSnapshot {
return models.StateSnapshot{
Hosts: []models.Host{{ID: "agent-one", Hostname: "guest-one", LinkedVMID: "pve:node:103", LastSeen: now}},
VMs: []models.VM{{ID: "pve:node:103", VMID: 103, Name: "pbs-vm-one", Instance: "pve", Node: "node", Status: "running", IPAddresses: []string{"10.2.0.13"}, LastSeen: now}},
PBSInstances: []models.PBSInstance{{ID: "pbs-one", Name: "backup-one", Host: "https://10.2.0.13:8007", LastSeen: now}},
}
}
for _, test := range []struct {
name string
mutate func(*models.StateSnapshot)
}{
{"same-ip-second-guest", func(s *models.StateSnapshot) {
s.VMs = append(s.VMs, models.VM{ID: "pve:node:204", VMID: 204, Name: "unrelated", Instance: "pve", Node: "node", Status: "running", IPAddresses: []string{"10.2.0.13"}, LastSeen: now})
}},
{"second-agent-on-guest", func(s *models.StateSnapshot) {
s.Hosts = append(s.Hosts, models.Host{ID: "agent-two", Hostname: "other", LinkedVMID: "pve:node:103", LastSeen: now})
}},
{"link-to-other-guest", func(s *models.StateSnapshot) {
s.Hosts[0].LinkedVMID = "pve:node:999"
}},
{"nonunique-endpoint", func(s *models.StateSnapshot) {
s.PBSInstances[0].Host = "https://127.0.0.1:8007"
s.VMs[0].IPAddresses = []string{"127.0.0.1"}
}},
{"stopped-guest", func(s *models.StateSnapshot) {
s.VMs[0].Status = "stopped"
}},
{"stale-guest", func(s *models.StateSnapshot) {
s.VMs[0].LastSeen = now.Add(-10 * time.Minute)
}},
{"stale-agent", func(s *models.StateSnapshot) {
s.Hosts[0].LastSeen = now.Add(-10 * time.Minute)
}},
} {
t.Run(test.name, func(t *testing.T) {
snapshot := base()
test.mutate(&snapshot)
registry := NewRegistry(nil)
registry.IngestSnapshot(snapshot)
for _, resource := range registry.ListForPresentation() {
if resource.Type == ResourceTypePBS && resource.PBS != nil && resource.PBS.LinkedAgentID != "" {
t.Fatalf("ambiguous PBS gained agent link %q", resource.PBS.LinkedAgentID)
}
if resource.Type == ResourceTypeAgent && containsDataSource(resource.Sources, SourcePBS) {
t.Fatalf("ambiguous host gained PBS source: %+v", resource)
}
}
})
}
}
func containsPlatformScope(values []string, expected string) bool {
for _, value := range values {
if value == expected {
+1 -1
View File
@@ -268,7 +268,7 @@ func (rr *ResourceRegistry) ingestSnapshot(snapshot models.StateSnapshot, thresh
}
for _, instance := range snapshot.PBSInstances {
rr.ingestPBSInstance(instance)
rr.associatePBSHostAgentResources(instance, snapshot.Hosts)
rr.associatePBSHostAgentResources(instance, snapshot.Hosts, snapshot.VMs)
}
for _, instance := range snapshot.PMGInstances {
rr.ingestPMGInstance(instance)