diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index bca90a4781..e2a48b9049 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -317,6 +317,8 @@ For token-auth PBS connections that cannot report `nodeName`, a registry-corrobo 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. +The operator's PBS connection label is never sufficient backend host identity. A label matching an unrelated Agent must not win over the endpoint's corroborated PVE guest-IP/Agent link or project that Agent's SMART disks under PBS. If only the label matches, the backend leaves the host link absent and the PBS service series separate; Backups presentation has its own correlation boundary. + 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 diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 65ffac235a..b19d9bc2a3 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -506,7 +506,7 @@ about the same disk cannot diverge. PBS presentation correlation accepts standalone agents and VM/system-container resources with an agent facet. Preserve unique identity matching and canonical metricsTarget type and ID. Guests without telemetry and mixed ambiguous agent/guest matches must not replace the PBS target. -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. +When the registry uniquely corroborates a PBS connection to a host agent by PBS-reported node name, configured endpoint hostname or reported interface IP, the PBS service may expose that source-native agent ID as `pbs.linkedAgentId`. The user-editable connection name alone is not host identity: it must not attach an unrelated Agent or preempt a corroborated VM-IP/Agent link. 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. @@ -5156,9 +5156,10 @@ wire-level contract. `internal/unifiedresources/pbs_host_disks.go` may add `pbs` source membership to an agent host and its agent-reported SMART disk resources only when one -host is uniquely corroborated by the PBS instance name, endpoint hostname, or -endpoint IP against the host report and interface addresses. Multiple matches -are ambiguous and must fail closed without assigning PBS ownership. +host is uniquely corroborated by the PBS-reported node name, endpoint hostname, +endpoint IP against the host report and interface addresses, or a fresh PVE +guest-IP/Agent link. The user-editable connection label is not identity evidence. +Multiple matches are ambiguous and must fail closed without assigning PBS ownership. The same unique match may set `PBSData.LinkedAgentID` on the PBS service only after the source-native host ID resolves to a canonical resource carrying that diff --git a/internal/unifiedresources/pbs_host_disks.go b/internal/unifiedresources/pbs_host_disks.go index 8963cf6d75..f795501647 100644 --- a/internal/unifiedresources/pbs_host_disks.go +++ b/internal/unifiedresources/pbs_host_disks.go @@ -172,10 +172,9 @@ func pbsInstanceCorroboratesHost(instance models.PBSInstance, host models.Host) if nodeName := NormalizeHostname(instance.NodeName); nodeName != "" && nodeName == hostName { return true } - if instanceName := NormalizeHostname(instance.Name); instanceName != "" && instanceName == hostName { - return true - } - + // The connection name is operator-chosen display text. Matching it to an + // Agent hostname can steal the link from the actual guest (and project its + // SMART disks under the wrong PBS server), so it is not identity evidence. endpoint := strings.TrimSpace(strings.ToLower(extractHostname(instance.Host))) if endpoint == "" { return false diff --git a/internal/unifiedresources/pbs_pmg_registry_test.go b/internal/unifiedresources/pbs_pmg_registry_test.go index 9451019591..bf31164d0e 100644 --- a/internal/unifiedresources/pbs_pmg_registry_test.go +++ b/internal/unifiedresources/pbs_pmg_registry_test.go @@ -470,6 +470,76 @@ func TestIngestSnapshotAssociatesPBSGuestAgentByExactVMIP(t *testing.T) { } } +// A connection label is chosen by the operator, not reported by PBS as its +// machine identity. It must not steal a corroborated guest Agent link merely +// because an unrelated Agent happens to have that hostname. +func TestIngestSnapshotPBSGuestLinkIgnoresConnectionLabelCollision(t *testing.T) { + now := time.Now().UTC() + registry := NewRegistry(nil) + registry.IngestSnapshot(models.StateSnapshot{ + Hosts: []models.Host{ + { + ID: "unrelated-agent", Hostname: "backup-one", Status: "online", LastSeen: now, + Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{Device: "/dev/sda", Serial: "UNRELATED-DISK"}}}, + }, + { + ID: "guest-agent", Hostname: "guest-one", LinkedVMID: "pve:node:103", Status: "online", LastSeen: now, + Sensors: models.HostSensorSummary{SMART: []models.HostDiskSMART{{Device: "/dev/sda", Serial: "GUEST-DISK"}}}, + }, + }, + VMs: []models.VM{{ + ID: "pve:node:103", VMID: 103, 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", Status: "online", LastSeen: now, + }}, + }) + var linkedAgentID string + for _, resource := range registry.ListForPresentation() { + if resource.Type == ResourceTypePBS && resource.PBS != nil { + linkedAgentID = resource.PBS.LinkedAgentID + } + if resource.Agent != nil && resource.Agent.AgentID == "unrelated-agent" && containsDataSource(resource.Sources, SourcePBS) { + t.Fatalf("connection label attached unrelated Agent: %+v", resource) + } + if resource.PhysicalDisk != nil { + switch resource.PhysicalDisk.Serial { + case "UNRELATED-DISK": + if containsDataSource(resource.Sources, SourcePBS) { + t.Fatalf("connection label attached unrelated SMART disk: %+v", resource) + } + case "GUEST-DISK": + if !containsDataSource(resource.Sources, SourcePBS) { + t.Fatalf("corroborated guest SMART disk lost PBS source: %+v", resource) + } + } + } + } + if linkedAgentID != "guest-agent" { + t.Fatalf("PBS linked agent = %q, want corroborated guest-agent", linkedAgentID) + } +} + +func TestIngestSnapshotPBSConnectionLabelAloneDoesNotLinkAgent(t *testing.T) { + now := time.Now().UTC() + registry := NewRegistry(nil) + registry.IngestSnapshot(models.StateSnapshot{ + Hosts: []models.Host{{ID: "unrelated-agent", Hostname: "backup-one", Status: "online", LastSeen: now}}, + PBSInstances: []models.PBSInstance{{ + ID: "pbs-one", Name: "backup-one", Host: "https://10.2.0.13:8007", Status: "online", LastSeen: now, + }}, + }) + for _, resource := range registry.ListForPresentation() { + if resource.Type == ResourceTypePBS && resource.PBS != nil && resource.PBS.LinkedAgentID != "" { + t.Fatalf("label-only PBS gained Agent link %q", resource.PBS.LinkedAgentID) + } + if resource.Agent != nil && containsDataSource(resource.Sources, SourcePBS) { + t.Fatalf("label-only Agent gained PBS source: %+v", resource) + } + } +} + func TestIngestSnapshotPBSGuestIPAssociationFailsClosed(t *testing.T) { now := time.Now().UTC() base := func() models.StateSnapshot {