Fix PBS backup attribution for VMIDs shared across clusters

v6.1.0's identity rework (eab73d245) made the VMID-only fallback refuse
to fire whenever a typed VMID exists on more than one PVE location.
Root-namespace snapshots with no matching comment then score zero for
every guest, so on setups with two clusters and overlapping VMIDs most
guests showed no backup at all - while PVE itself listed the backups
fine, because monitoring discards pbs-type storage contents entirely
whenever a direct PBS connection is configured.

Attribution is now evidence-driven instead of dropped:

Storage backup polling keeps a per-connection record of every snapshot
its own pbs-type storage listed (type, VMID, backup time) even though
the raw entries stay out of the PVE backup list. Which cluster listed a
snapshot is deterministic attribution, and it survives fully mirrored
clusters that share one datastore and token. The evidence is
monitoring-internal, cleared on instance retirement or when the storage
poll stops seeing pbs content, and never serialized into state payloads
or snapshots.

Guest backup-time sync and the recovery-point mapper additionally learn
each PBS submission source's cluster (owner token, datastore, PBS
instance - strongest first, scoped to the PBS instance) from the poll's
attributable snapshots, then resolve collision VMIDs whose snapshots
carry no evidence of their own. A source seen from several clusters is
not a discriminator, an unfamiliar component stops resolution rather
than deferring to weaker ones, and a snapshot decisively attributed to
another cluster is kept away from this one. Unattributable snapshots
still drop rather than guess.

Backup-age alert attribution no longer suffix-matches the subject ref's
connection label against guest locations. The label there is a PVE or
PBS instance name, not a PBS namespace, and loose matching could
cross-attribute clusters sharing a VMID; it now requires exact
normalized equality.

Reported in #1639 (two PVE clusters with PBS 4.0/4.1, VM 173 shown 974
days overdue despite valid verified backups).

Fixes #1639

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
courtmanr@gmail.com 2026-07-28 11:23:39 +01:00
parent ac43506e6e
commit 84dba861b5
19 changed files with 990 additions and 19 deletions

View file

@ -410,6 +410,11 @@ update, profile rollout, command reachability, or fleet-control authority.
24. `internal/config/host_continuity.go` shared with `monitoring`: the durable host identity, report-order watermark, and removal tombstone journal is jointly owned by agent lifecycle admission and monitoring report continuity.
25. `internal/kubernetesagent/agent.go` shared with `monitoring`: the Kubernetes native agent runtime is both a monitoring inventory source and an agent lifecycle Pulse control-plane transport client.
26. `internal/models/models.go` shared with `monitoring`: removed host-agent identity aliases and tombstone state are both agent lifecycle authority and monitoring runtime report state.
The PVE-side PBS guest-backup confirmation evidence the same file carries
is monitoring-owned attribution state for Proxmox backup freshness: it is
internal-only, never serialized into state payloads or snapshots, and does
not describe agent enrollment, report admission, tombstones,
re-enrollment, or command authority.
27. `internal/monitoring/monitor.go` shared with `monitoring`: monitor construction owns both monitoring runtime initialization and fail-closed agent lifecycle journal hydration before report admission.
28. `internal/monitoring/monitor_agents.go` shared with `monitoring`: server-side Unified Agent report, removal, token binding, tombstone expiry, and re-enrollment semantics are jointly owned by agent lifecycle authority and monitoring ingest.
29. `pkg/agents/host/report.go` shared with `monitoring`: the Unified Agent host report is both an agent lifecycle authored-state contract and a monitoring ingest contract for host maintenance posture.

View file

@ -32,6 +32,13 @@ state must be explicitly true. Exit code 137 alone is only SIGKILL evidence;
explicit false and unavailable/legacy OOM state both fail closed without an OOM
alert. Recovery clears an existing OOM alert when the authoritative predicate
is no longer true.
Backup-age alert attribution of an unlinked recovery rollup treats the
subject ref's namespace field as a connection label, not a PBS namespace: it
may match a candidate guest's instance or node only by exact normalized
equality. Suffix matching is reserved for real PBS namespaces inside the
shared identity helpers; applying it to a PBS connection name
cross-attributes clusters that share a VMID, so an unlinked backup whose
label identifies no guest exactly stays on its generic rollup key.
Availability incident and alert identity belongs to the source-owned
`network-endpoint` check. Correlation may project probe evidence onto a matched
machine, but it must not copy the check incident onto that machine or create a

View file

@ -82,6 +82,22 @@ full backup point batch per poll cycle. Complete authoritative enumerations
coalesce only within the same provider, ID-prefix, and instance scope; distinct
source scopes and non-reconciling event batches remain FIFO so bounding memory
does not discard independent recovery facts.
PBS snapshot-to-guest attribution for VMIDs that exist on more than one PVE
location is evidence-driven, never guessed. When the direct PBS connection is
authoritative and pbs-type storage contents are dropped from the PVE backup
list, storage backup polling must still harvest each listed snapshot as a
per-connection guest confirmation (type, VMID, backup time): which cluster
listed a snapshot through its own storage is the only deterministic
attribution for collision VMIDs with no namespace or comment evidence. Guest
backup-time sync consumes those confirmations first, then a
submission-source mapping (owner token, datastore, PBS instance — scoped to
the PBS instance, strongest first) learned from the same poll's attributable
snapshots; a source component that was never positively attributed stops
resolution rather than deferring to weaker components. Snapshots that remain
unattributable stay dropped for colliding guests, and the confirmation
evidence is monitoring-internal state: cleared when a PVE instance is
retired or its storage poll returns no pbs-type content, and never
serialized into state payloads or snapshots.
Removed host-agent reconnect blocks are identity-scoped: matching may use the
canonical host ID or token-qualified machine/hostname continuity, but must never
block a distinct live host by hostname alone.

View file

@ -5436,6 +5436,7 @@
"internal/dockeragent/swarm_coverage_test.go",
"internal/models/deepcopy_test.go",
"internal/models/host_transient_expiry_test.go",
"internal/models/issue1639_pbs_collision_test.go",
"internal/models/metrics_types_test.go",
"internal/models/state_host_test.go",
"internal/monitoring/issue1595_collection_trust_test.go",
@ -5572,6 +5573,7 @@
"test_prefixes": [],
"exact_files": [
"internal/monitoring/monitor_alert_intent_test.go",
"internal/monitoring/monitor_backups_dir_storage_test.go",
"internal/monitoring/monitor_full_coverage_test.go",
"internal/monitoring/pbs_protection_observation_test.go",
"internal/monitoring/recovery_ingest_test.go",

View file

@ -49,6 +49,15 @@ archive, backup task, recovery-point, restore, PBS, routing, or credential key.
Search includes both presentation and native diagnostics, duplicate display
values are allowed, and clearing an override deterministically restores the
current native node name.
Recovery-point mapping of PBS snapshots whose VMID exists on more than one
PVE connection may link a snapshot only on positive evidence: namespace
placement, guest-name label, or the submission-source mapping (owner token,
datastore, PBS instance — strongest first, scoped to the PBS instance)
learned from the same batch's attributable snapshots. A source component
never positively attributed stops resolution instead of deferring to weaker
components, and a snapshot whose source decisively belongs to a connection
with no matching guest stays unlinked; it must never be linked to another
cluster's guest with the same VMID.
Physical-disk to node association is evidence-directional. A disk reported by a

View file

@ -1645,9 +1645,12 @@ func TestCheckBackupsHandlesPbsOnlyGuests(t *testing.T) {
func TestCheckBackupsDisambiguatesWithNamespace(t *testing.T) {
// Test that when multiple guests have the same VMID from different instances,
// the namespace is used to match the backup to the correct guest.
// This addresses issue #1095 where users have multiple PVE instances with
// overlapping VMIDs and separate PBS instances backing them up.
// the subject ref's connection label is used to match the backup to the
// correct guest. This addresses issue #1095 where users have multiple PVE
// instances with overlapping VMIDs and separate PBS instances backing
// them up. The label must match a guest's instance or node exactly:
// suffix matching let a PBS instance name cross-attribute clusters that
// share a VMID (#1639), so "nat" no longer matches "pve-nat".
m := newTestManager(t)
m.ClearActiveAlerts()
@ -1690,13 +1693,14 @@ func TestCheckBackupsDisambiguatesWithNamespace(t *testing.T) {
},
}
// PBS backup with namespace "nat" should match the "pve-nat" instance
// A subject ref labeled with the exact connection name matches the
// "pve-nat" instance.
rollups := []recovery.ProtectionRollup{
{
RollupID: "ext:pbs-nat-100",
SubjectRef: &recovery.ExternalRef{
Type: "proxmox-vm",
Namespace: "nat", // namespace "nat" should match the pve-nat location
Namespace: "pve-nat", // exact connection label for the pve-nat location
Name: "100",
ID: "100",
},
@ -1976,6 +1980,86 @@ func TestCheckBackupsVMIDCollisionNoNamespace(t *testing.T) {
}
}
// TestCheckBackupsIssue1639PBSInstanceLabelDoesNotCrossAttribute verifies
// that a PBS connection name that merely suffix-matches one guest's
// instance name does not attribute the backup to that guest. The subject
// ref of an unlinked PBS rollup carries the PBS instance name in Namespace,
// and loose matching let it cross-attribute clusters sharing a VMID.
func TestCheckBackupsIssue1639PBSInstanceLabelDoesNotCrossAttribute(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()
m.mu.Lock()
m.config.Enabled = true
m.config.BackupDefaults = BackupAlertConfig{
Enabled: true,
WarningDays: 3,
CriticalDays: 5,
}
m.mu.Unlock()
now := time.Now()
guestsByKey := map[string]GuestLookup{
"pve-nat-node2-100": {
ResourceID: "qemu/100",
Name: "webserver-nat",
Instance: "pve-nat",
Node: "node2",
Type: "qemu",
VMID: 100,
},
"pve-main-node1-100": {
ResourceID: "qemu/100",
Name: "webserver-main",
Instance: "pve-main",
Node: "node1",
Type: "qemu",
VMID: 100,
},
}
guestsByVMID := map[string][]GuestLookup{
"100": {
guestsByKey["pve-nat-node2-100"],
guestsByKey["pve-main-node1-100"],
},
}
// "nat" is the PBS connection name, not a namespace. It suffix-matches
// "pve-nat" but identifies neither guest; the alert must stay on the
// generic rollup key instead of guessing.
rollups := []recovery.ProtectionRollup{
{
RollupID: "ext:pbs-nat-100",
SubjectRef: &recovery.ExternalRef{
Type: "proxmox-vm",
Namespace: "nat",
Name: "100",
ID: "100",
},
LastSuccessAt: ptrTime(now.Add(-6 * 24 * time.Hour)),
LastOutcome: recovery.OutcomeSuccess,
Providers: []recovery.Provider{recovery.ProviderProxmoxPBS},
},
}
m.CheckBackups(rollups, guestsByKey, guestsByVMID)
m.mu.RLock()
defer m.mu.RUnlock()
if _, exists := testLookupActiveAlert(t, m, "backup-age-pve-nat-node2-100"); exists {
t.Fatal("PBS connection label cross-attributed the backup to pve-nat")
}
var keys []string
for storageKey, active := range m.activeAlerts {
keys = append(keys, effectiveAlertID(active, storageKey))
}
if len(keys) != 1 || !strings.Contains(keys[0], "ext-pbs-nat-100") {
t.Fatalf("expected a single generic rollup-keyed alert, found keys: %v", keys)
}
}
func TestCheckBackupsTypeMismatchDoesNotAttributeToWrongGuest(t *testing.T) {
m := newTestManager(t)
m.ClearActiveAlerts()

View file

@ -686,17 +686,23 @@ func (m *Manager) CheckBackupsWithInventory(
if len(guests) == 1 {
info = guests[0]
} else if len(guests) > 1 && strings.TrimSpace(ref.Namespace) != "" {
// ref.Namespace carries a connection label here (the
// PVE or PBS instance name from the mapper), not a
// PBS namespace, so it must match a guest's location
// exactly. Suffix matching a PBS instance name
// against node names cross-attributes clusters that
// share a VMID (#1639).
bestScore := 0
matchedMultiple := false
for _, g := range guests {
score := proxmoxidentity.BackupGuestMatchScore(
ref.Namespace,
ref.Name,
vmidStr,
g.Name,
g.Instance,
g.Node,
)
score := 0
if proxmoxidentity.LocationLabelsEqual(ref.Namespace, g.Instance) ||
proxmoxidentity.LocationLabelsEqual(ref.Namespace, g.Node) {
score += 10
}
if proxmoxidentity.BackupCommentMatchesGuestName(ref.Name, vmidStr, g.Name) {
score += 5
}
if score > bestScore {
bestScore = score
info = g

View file

@ -0,0 +1,238 @@
package models
import (
"testing"
"time"
)
// Issue #1639: two PVE clusters with the same VMID, root-namespace PBS
// snapshots, and no comment left both guests with zero LastBackup because
// the ambiguity guard disabled the VMID-only fallback and the scorer never
// consulted the snapshots' provenance (owner token, datastore, PBS
// instance) or the PVE-side storage listings.
// TestIssue1639PBSCollisionResolvedBySubmissionSource verifies that when
// each cluster's submission source is learnable from its other,
// non-colliding guests, root-namespace comment-less snapshots for a
// colliding VMID are attributed to the correct cluster.
func TestIssue1639PBSCollisionResolvedBySubmissionSource(t *testing.T) {
state := NewState()
now := time.Now()
backupTimeA := now.Add(-2 * time.Hour)
backupTimeB := now.Add(-3 * time.Hour)
uniqueTimeA := now.Add(-26 * time.Hour)
uniqueTimeB := now.Add(-27 * time.Hour)
state.UpdateVMs([]VM{
// VMID 173 exists on both clusters — the reported collision.
{VMID: 173, Name: "web-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 173, Name: "web-b", Instance: "cluster-b", Node: "pve-b1"},
// Each cluster also has a unique VMID whose snapshots teach the
// learner that cluster's owner token and datastore.
{VMID: 100, Name: "db-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 150, Name: "db-b", Instance: "cluster-b", Node: "pve-b1"},
})
state.mu.Lock()
state.PBSBackups = []PBSBackup{
// Teaching snapshots: unique VMIDs, root namespace, no comment.
{ID: "a-100", VMID: "100", BackupType: "vm", BackupTime: uniqueTimeA,
Instance: "pbs-main", Datastore: "store-a", Owner: "cluster-a@pbs!token"},
{ID: "b-150", VMID: "150", BackupType: "vm", BackupTime: uniqueTimeB,
Instance: "pbs-main", Datastore: "store-b", Owner: "cluster-b@pbs!token"},
// Colliding VMID with no namespace/comment evidence, distinct
// owner and datastore per cluster.
{ID: "a-173", VMID: "173", BackupType: "vm", BackupTime: backupTimeA,
Instance: "pbs-main", Datastore: "store-a", Owner: "cluster-a@pbs!token"},
{ID: "b-173", VMID: "173", BackupType: "vm", BackupTime: backupTimeB,
Instance: "pbs-main", Datastore: "store-b", Owner: "cluster-b@pbs!token"},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
got := map[string]time.Time{}
for _, vm := range snapshot.VMs {
if vm.VMID == 173 {
got[vm.Instance] = vm.LastBackup
}
}
if !got["cluster-a"].Equal(backupTimeA) {
t.Errorf("cluster-a VM 173 LastBackup = %v, want its own snapshot %v", got["cluster-a"], backupTimeA)
}
if !got["cluster-b"].Equal(backupTimeB) {
t.Errorf("cluster-b VM 173 LastBackup = %v, want its own snapshot %v", got["cluster-b"], backupTimeB)
}
}
// TestIssue1639PBSCollisionResolvedByDistinctPBSInstance verifies the
// weakest source component: each cluster pushes to its own PBS instance,
// same datastore name, no owner reported.
func TestIssue1639PBSCollisionResolvedByDistinctPBSInstance(t *testing.T) {
state := NewState()
now := time.Now()
backupTimeA := now.Add(-2 * time.Hour)
backupTimeB := now.Add(-3 * time.Hour)
state.UpdateVMs([]VM{
{VMID: 173, Name: "web-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 173, Name: "web-b", Instance: "cluster-b", Node: "pve-b1"},
{VMID: 100, Name: "db-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 150, Name: "db-b", Instance: "cluster-b", Node: "pve-b1"},
})
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{ID: "a-100", VMID: "100", BackupType: "vm", BackupTime: now.Add(-26 * time.Hour), Instance: "pbs-one", Datastore: "backups"},
{ID: "b-150", VMID: "150", BackupType: "vm", BackupTime: now.Add(-27 * time.Hour), Instance: "pbs-two", Datastore: "backups"},
{ID: "a-173", VMID: "173", BackupType: "vm", BackupTime: backupTimeA, Instance: "pbs-one", Datastore: "backups"},
{ID: "b-173", VMID: "173", BackupType: "vm", BackupTime: backupTimeB, Instance: "pbs-two", Datastore: "backups"},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
switch {
case vm.VMID == 173 && vm.Instance == "cluster-a":
if !vm.LastBackup.Equal(backupTimeA) {
t.Errorf("cluster-a VM 173 LastBackup = %v, want %v", vm.LastBackup, backupTimeA)
}
case vm.VMID == 173 && vm.Instance == "cluster-b":
if !vm.LastBackup.Equal(backupTimeB) {
t.Errorf("cluster-b VM 173 LastBackup = %v, want %v", vm.LastBackup, backupTimeB)
}
}
}
}
// TestIssue1639PBSCollisionSharedSourceStaysDropped verifies the guard is
// not weakened: when both clusters push through the same owner, datastore,
// and PBS instance and no PVE-side evidence exists, the snapshots stay
// unattributed rather than being guessed onto a cluster.
func TestIssue1639PBSCollisionSharedSourceStaysDropped(t *testing.T) {
state := NewState()
now := time.Now()
state.UpdateVMs([]VM{
{VMID: 173, Name: "web-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 173, Name: "web-b", Instance: "cluster-b", Node: "pve-b1"},
{VMID: 100, Name: "db-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 150, Name: "db-b", Instance: "cluster-b", Node: "pve-b1"},
})
state.mu.Lock()
state.PBSBackups = []PBSBackup{
// Both clusters share one submission source, so nothing about the
// colliding snapshots discriminates between them.
{ID: "a-100", VMID: "100", BackupType: "vm", BackupTime: now.Add(-26 * time.Hour), Instance: "pbs-main", Datastore: "backups", Owner: "shared@pbs!token"},
{ID: "b-150", VMID: "150", BackupType: "vm", BackupTime: now.Add(-27 * time.Hour), Instance: "pbs-main", Datastore: "backups", Owner: "shared@pbs!token"},
{ID: "x-173", VMID: "173", BackupType: "vm", BackupTime: now.Add(-2 * time.Hour), Instance: "pbs-main", Datastore: "backups", Owner: "shared@pbs!token"},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
if vm.VMID == 173 && !vm.LastBackup.IsZero() {
t.Errorf("VM 173 on %s should stay unattributed with a shared submission source, got %v", vm.Instance, vm.LastBackup)
}
}
}
// TestIssue1639PBSCollisionResolvedByPVEStorageConfirmation verifies the
// deterministic path: even with identical submission sources (fully
// mirrored clusters pushing to one datastore with one token), each PVE
// connection's own pbs-type storage listing attributes its snapshots.
func TestIssue1639PBSCollisionResolvedByPVEStorageConfirmation(t *testing.T) {
state := NewState()
now := time.Now()
backupTimeA := now.Add(-2 * time.Hour).Truncate(time.Second)
backupTimeB := now.Add(-3 * time.Hour).Truncate(time.Second)
state.UpdateVMs([]VM{
{VMID: 173, Name: "web", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 173, Name: "web", Instance: "cluster-b", Node: "pve-b1"},
})
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{ID: "a-173", VMID: "173", BackupType: "vm", BackupTime: backupTimeA, Instance: "pbs-main", Datastore: "backups", Owner: "shared@pbs!token"},
{ID: "b-173", VMID: "173", BackupType: "vm", BackupTime: backupTimeB, Instance: "pbs-main", Datastore: "backups", Owner: "shared@pbs!token"},
}
state.mu.Unlock()
state.UpdatePBSGuestConfirmationsForInstance("cluster-a", []PBSGuestConfirmation{
{BackupType: "vm", VMID: 173, Time: backupTimeA.Unix()},
})
state.UpdatePBSGuestConfirmationsForInstance("cluster-b", []PBSGuestConfirmation{
{BackupType: "vm", VMID: 173, Time: backupTimeB.Unix()},
})
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
switch vm.Instance {
case "cluster-a":
if !vm.LastBackup.Equal(backupTimeA) {
t.Errorf("cluster-a VM 173 LastBackup = %v, want confirmed snapshot %v", vm.LastBackup, backupTimeA)
}
case "cluster-b":
if !vm.LastBackup.Equal(backupTimeB) {
t.Errorf("cluster-b VM 173 LastBackup = %v, want confirmed snapshot %v", vm.LastBackup, backupTimeB)
}
}
}
}
// TestIssue1639PBSCollisionForeignSourceRejected verifies negative
// attribution: a snapshot whose source decisively belongs to another
// cluster must not reach this cluster's guest even though the guest has no
// snapshot of its own.
func TestIssue1639PBSCollisionForeignSourceRejected(t *testing.T) {
state := NewState()
now := time.Now()
state.UpdateVMs([]VM{
{VMID: 173, Name: "web-a", Instance: "cluster-a", Node: "pve-a1"},
{VMID: 173, Name: "web-b", Instance: "cluster-b", Node: "pve-b1"},
{VMID: 100, Name: "db-a", Instance: "cluster-a", Node: "pve-a1"},
})
state.mu.Lock()
state.PBSBackups = []PBSBackup{
// Only cluster-a's source is learnable, and the only 173 snapshot
// belongs to it. cluster-b must stay at zero.
{ID: "a-100", VMID: "100", BackupType: "vm", BackupTime: now.Add(-26 * time.Hour), Instance: "pbs-main", Datastore: "store-a", Owner: "cluster-a@pbs!token"},
{ID: "a-173", VMID: "173", BackupType: "vm", BackupTime: now.Add(-2 * time.Hour), Instance: "pbs-main", Datastore: "store-a", Owner: "cluster-a@pbs!token"},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
if vm.VMID != 173 {
continue
}
switch vm.Instance {
case "cluster-a":
if vm.LastBackup.IsZero() {
t.Error("cluster-a VM 173 should get its own snapshot")
}
case "cluster-b":
if !vm.LastBackup.IsZero() {
t.Errorf("cluster-b VM 173 must not inherit cluster-a's snapshot, got %v", vm.LastBackup)
}
}
}
}

View file

@ -209,3 +209,31 @@ func TestNativePoolHealthEvidenceNormalizesWithoutChangingIdentity(t *testing.T)
t.Fatalf("normalized Ceph evidence = %+v", cluster)
}
}
// TestPBSGuestConfirmationEvidenceStaysOutOfSerializedState guards the
// boundary the #1639 fix introduced: PVE-side PBS snapshot confirmations
// are monitoring-internal attribution evidence on State. They must never
// leak into serialized state or snapshots, where they would read as an
// agent- or API-facing contract.
func TestPBSGuestConfirmationEvidenceStaysOutOfSerializedState(t *testing.T) {
state := NewState()
state.UpdatePBSGuestConfirmationsForInstance("cluster-a", []PBSGuestConfirmation{
{BackupType: "vm", VMID: 173, Time: time.Now().Unix()},
})
payload, err := json.Marshal(state)
if err != nil {
t.Fatalf("marshal state: %v", err)
}
if strings.Contains(string(payload), "pbsGuestConfirmations") || strings.Contains(string(payload), "PBSGuestConfirmation") {
t.Fatal("PBS guest confirmation evidence must not serialize with state")
}
snapshotPayload, err := json.Marshal(state.GetSnapshot())
if err != nil {
t.Fatalf("marshal snapshot: %v", err)
}
if strings.Contains(string(snapshotPayload), "pbsGuestConfirmations") || strings.Contains(string(snapshotPayload), "PBSGuestConfirmation") {
t.Fatal("PBS guest confirmation evidence must not appear in snapshots")
}
}

View file

@ -48,6 +48,26 @@ type State struct {
TemperatureMonitoringEnabled bool `json:"temperatureMonitoringEnabled"`
PVETagColors map[string]string `json:"pveTagColors,omitempty"`
PVETagStyles map[string]PVETagStyle `json:"pveTagStyles,omitempty"`
// pbsGuestConfirmations records, per PVE connection, the (type, vmid,
// time) triples that connection's own pbs-type storage listings
// reported. When direct PBS polling is authoritative the raw storage
// contents are not kept as StorageBackups, but which cluster listed a
// snapshot is the only deterministic way to attribute a PBS snapshot
// whose VMID exists on several clusters (#1639). Internal evidence only:
// never serialized and never part of snapshots.
pbsGuestConfirmations map[string]map[PBSGuestConfirmation]struct{}
}
// PBSGuestConfirmation identifies one PBS snapshot as seen through a PVE
// connection's own pbs-type storage content listing. BackupType uses PBS
// nomenclature ("vm" or "ct") and Time is the snapshot's backup time in
// unix seconds, which matches PBSBackup.BackupTime exactly because both
// derive from the snapshot identifier.
type PBSGuestConfirmation struct {
BackupType string
VMID int
Time int64
}
// PVETagStyle is the frontend-facing Proxmox tag style for one PVE instance.
@ -4107,6 +4127,36 @@ func backupKey(instance string, vmid int) string {
return instance + "-" + strconv.Itoa(vmid)
}
// UpdatePBSGuestConfirmationsForInstance replaces the PVE-side PBS snapshot
// evidence for one connection. Pass an empty slice when the connection's
// storage poll saw no pbs-type storage content so stale evidence cannot
// outlive the snapshots it described.
func (s *State) UpdatePBSGuestConfirmationsForInstance(instanceName string, confirmations []PBSGuestConfirmation) {
s.mu.Lock()
defer s.mu.Unlock()
if s.pbsGuestConfirmations == nil {
s.pbsGuestConfirmations = make(map[string]map[PBSGuestConfirmation]struct{})
}
if len(confirmations) == 0 {
delete(s.pbsGuestConfirmations, instanceName)
return
}
set := make(map[PBSGuestConfirmation]struct{}, len(confirmations))
for _, confirmation := range confirmations {
confirmation.BackupType = strings.ToLower(strings.TrimSpace(confirmation.BackupType))
if (confirmation.BackupType != "vm" && confirmation.BackupType != "ct") || confirmation.VMID <= 0 || confirmation.Time <= 0 {
continue
}
set[confirmation] = struct{}{}
}
if len(set) == 0 {
delete(s.pbsGuestConfirmations, instanceName)
return
}
s.pbsGuestConfirmations[instanceName] = set
}
// SyncGuestBackupTimes updates LastBackup on VMs and Containers from storage backups and PBS backups.
// Call this after updating storage backups or PBS backups to ensure guest backup indicators are accurate.
// Matching is done by instance+VMID to prevent cross-instance VMID collisions.
@ -4155,7 +4205,13 @@ func (s *State) SyncGuestBackupTimes() {
// Build a set of typed VMIDs that appear on more than one PVE location.
// When a typed VMID is ambiguous, we must not fall back to VMID-only matching
// because we can't tell which guest the backup belongs to.
type pbsSubjectGuest struct {
instance string
node string
name string
}
subjectLocations := make(map[pbsSubjectKey]map[string]struct{})
subjectGuests := make(map[pbsSubjectKey][]pbsSubjectGuest)
for i := range s.VMs {
key := pbsSubjectKey{backupType: "vm", vmid: s.VMs[i].VMID}
m, ok := subjectLocations[key]
@ -4164,6 +4220,7 @@ func (s *State) SyncGuestBackupTimes() {
subjectLocations[key] = m
}
m[backupKey(s.VMs[i].Instance, s.VMs[i].VMID)+"@"+s.VMs[i].Node] = struct{}{}
subjectGuests[key] = append(subjectGuests[key], pbsSubjectGuest{instance: s.VMs[i].Instance, node: s.VMs[i].Node, name: s.VMs[i].Name})
}
for i := range s.Containers {
key := pbsSubjectKey{backupType: "ct", vmid: s.Containers[i].VMID}
@ -4173,6 +4230,7 @@ func (s *State) SyncGuestBackupTimes() {
subjectLocations[key] = m
}
m[backupKey(s.Containers[i].Instance, s.Containers[i].VMID)+"@"+s.Containers[i].Node] = struct{}{}
subjectGuests[key] = append(subjectGuests[key], pbsSubjectGuest{instance: s.Containers[i].Instance, node: s.Containers[i].Node, name: s.Containers[i].Name})
}
subjectIsAmbiguous := make(map[pbsSubjectKey]bool)
for key, locations := range subjectLocations {
@ -4181,6 +4239,57 @@ func (s *State) SyncGuestBackupTimes() {
}
}
// PVE-side confirmations deterministically attribute a PBS snapshot to
// the connection whose own pbs-type storage listed it. Index them by
// snapshot identity, keeping the set of confirming connections so a
// snapshot listed by several connections (shouldn't happen, but trust
// nothing) stays inconclusive.
confirmingInstances := make(map[PBSGuestConfirmation]map[string]struct{})
for instanceName, confirmations := range s.pbsGuestConfirmations {
for confirmation := range confirmations {
set, ok := confirmingInstances[confirmation]
if !ok {
set = make(map[string]struct{})
confirmingInstances[confirmation] = set
}
set[instanceName] = struct{}{}
}
}
// Learn which PVE connection each PBS submission source (owner token,
// datastore, PBS instance) belongs to from the backups that CAN be
// attributed — unique typed VMIDs, or placement/name matches. The
// learned mapping then resolves collision-VMID snapshots that carry no
// namespace or comment evidence of their own (#1639).
sourceLearner := proxmoxidentity.NewPBSSourceLearner()
for key, backups := range pbsBackupsBySubject {
guests := subjectGuests[key]
if len(guests) == 0 {
continue
}
for _, backup := range backups {
attributed := ""
if !subjectIsAmbiguous[key] {
attributed = guests[0].instance
} else {
matched := make(map[string]struct{})
for _, guest := range guests {
if proxmoxidentity.BackupGuestMatchScore(backup.Namespace, backup.Comment, backup.VMID, guest.name, guest.instance, guest.node) > 0 {
matched[guest.instance] = struct{}{}
}
}
if len(matched) == 1 {
for instance := range matched {
attributed = instance
}
}
}
if attributed != "" {
sourceLearner.Observe(backup.Instance, backup.Datastore, backup.Owner, attributed)
}
}
}
// findBestPBSBackup finds the best PBS backup for a given typed VMID and guest location.
// Placement and guest-name matches are preferred over VMID-only fallback.
// Returns zero time if no suitable backup found.
@ -4203,8 +4312,26 @@ func (s *State) SyncGuestBackupTimes() {
instance,
node,
)
if score == 0 && !subjectIsAmbiguous[subjectKey] {
score = 1
if score == 0 {
if !subjectIsAmbiguous[subjectKey] {
score = 1
} else if confirmers := confirmingInstances[PBSGuestConfirmation{BackupType: backupType, VMID: vmid, Time: backup.BackupTime.Unix()}]; len(confirmers) == 1 {
// Exactly one connection's own PBS storage listed this
// snapshot — it belongs to that cluster, full stop.
if _, ours := confirmers[instance]; ours {
score = 2
}
} else {
// No usable storage-view evidence (unconfirmed, or a
// shared datastore mount several clusters list); fall
// back to the learned submission-source mapping. A
// decisive attribution to another cluster keeps the
// snapshot away from this guest; an inconclusive one
// keeps the pre-#1639 drop.
if attributed, decisive := sourceLearner.Resolve(backup.Instance, backup.Datastore, backup.Owner); decisive && attributed == instance {
score = 1
}
}
}
if score <= 0 {
continue

View file

@ -1159,7 +1159,9 @@ func TestSyncGuestBackupTimesClearsStaleBackupWhenCurrentEvidenceDisappears(t *t
// TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace verifies that when the same VMID
// exists on multiple PVE instances and a PBS backup namespace matches neither, both guests
// get zero LastBackup instead of a false positive.
// get zero LastBackup instead of a false positive. The backup's submission
// source (owner/datastore) carries no learned attribution and no PVE-side
// storage listing confirms it, so the #1639 evidence paths must not fire.
func TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace(t *testing.T) {
state := NewState()
@ -1182,6 +1184,8 @@ func TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace(t *testing.T) {
BackupType: "vm",
BackupTime: backupTime,
Instance: "pbs-main",
Datastore: "backups",
Owner: "unlearned@pbs!token",
},
}
state.mu.Unlock()
@ -1199,7 +1203,10 @@ func TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace(t *testing.T) {
// TestSyncGuestBackupTimesVMIDCollisionEmptyNamespace verifies that when the same VMID
// exists on multiple PVE instances and a PBS backup has no namespace, both guests
// get zero LastBackup.
// get zero LastBackup. This is the genuinely unattributable core of #1639:
// with a single snapshot, no learnable submission source, and no PVE-side
// confirmation, guessing either guest would be a false positive. The
// resolvable variants live in issue1639_pbs_collision_test.go.
func TestSyncGuestBackupTimesVMIDCollisionEmptyNamespace(t *testing.T) {
state := NewState()

View file

@ -149,6 +149,7 @@ func (m *Monitor) backupInventoryScopeForAlerts() *alerts.BackupInventoryScope {
func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName string, client PVEClientInterface, nodes []proxmox.Node, nodeEffectiveStatus map[string]string) {
var allBackups []models.StorageBackup
var pbsConfirmations []models.PBSGuestConfirmation
hasPBSDirectConnection := m.config != nil && len(m.config.PBSInstances) > 0
seenVolids := make(map[string]bool) // Track seen volume IDs to avoid duplicates
hadSuccessfulNode := false // Track if at least one node responded successfully
@ -306,6 +307,19 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
// connection was also configured (#1592).
isPBSStorage := storage.Type == "pbs"
if isPBSStorage && hasPBSDirectConnection {
// The direct PBS connection is authoritative for the
// backup list, but which cluster listed a snapshot is
// evidence only this storage view has: it attributes a
// snapshot whose VMID exists on several clusters to the
// cluster that made it (#1639). Keep that association
// even though the raw entry is dropped.
if confirmationType := pbsGuestConfirmationType(backupType); confirmationType != "" && content.VMID > 0 && content.CTime > 0 {
pbsConfirmations = append(pbsConfirmations, models.PBSGuestConfirmation{
BackupType: confirmationType,
VMID: content.VMID,
Time: content.CTime,
})
}
log.Debug().
Str("instance", instanceName).
Str("node", node.Node).
@ -384,6 +398,7 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
// Update state with storage backups for this instance
m.state.UpdateStorageBackupsForInstance(instanceName, allBackups)
m.state.UpdatePBSGuestConfirmationsForInstance(instanceName, pbsConfirmations)
// Best-effort ingestion into recovery store (for rollups / unified backups UX).
guestInfo := buildProxmoxGuestInfoIndex(readState)
@ -425,6 +440,20 @@ func (m *Monitor) pollStorageBackupsWithNodes(ctx context.Context, instanceName
m.broadcastStateUpdate()
}
// pbsGuestConfirmationType maps a PVE storage-content backup type to PBS
// snapshot nomenclature. Host and unclassifiable entries return "" and are
// not usable as guest attribution evidence.
func pbsGuestConfirmationType(backupType string) string {
switch backupType {
case "qemu":
return "vm"
case "lxc":
return "ct"
default:
return ""
}
}
func shouldPreserveBackups(nodeCount int, hadSuccessfulNode bool, storagesWithBackup, contentSuccess int) bool {
if nodeCount > 0 && !hadSuccessfulNode {
return true
@ -1330,6 +1359,7 @@ func (m *Monitor) removeFailedPVENode(instanceName string) {
m.state.UpdateCephClustersForInstance(instanceName, []models.CephCluster{})
m.state.UpdateBackupTasksForInstance(instanceName, []models.BackupTask{})
m.state.UpdateStorageBackupsForInstance(instanceName, []models.StorageBackup{})
m.state.UpdatePBSGuestConfirmationsForInstance(instanceName, nil)
m.state.UpdateGuestSnapshotsForInstance(instanceName, []models.GuestSnapshot{})
// Set connection health to false

View file

@ -68,3 +68,76 @@ func TestPollStorageBackups_KeepsDirStorageNamedLikePBS(t *testing.T) {
t.Fatalf("expected vzdump from dir storage named pbs-backup, got %+v", backups[0])
}
}
// pbsCollisionStorageClient serves one pbs-type storage listing a single
// snapshot for VMID 173, standing in for cluster-a's own PBS storage view.
type pbsCollisionStorageClient struct {
mockPVEClientExtra
snapshotTime time.Time
}
func (c *pbsCollisionStorageClient) GetStorage(ctx context.Context, node string) ([]pveapi.Storage, error) {
return []pveapi.Storage{
{Storage: "pbs-store", Content: "backup", Type: "pbs", Enabled: 1, Active: 1},
}, nil
}
func (c *pbsCollisionStorageClient) GetStorageContent(ctx context.Context, node, storage string) ([]pveapi.StorageContent, error) {
return []pveapi.StorageContent{{
Volid: "pbs-store:backup/vm/173/2026-07-27T01:00:00Z",
VMID: 173,
Size: 2048,
CTime: c.snapshotTime.Unix(),
Content: "backup",
Format: "pbs-vm",
}}, nil
}
// TestPollStorageBackups_Issue1639HarvestsPBSGuestConfirmations verifies
// that snapshots skipped in favor of the direct PBS connection still leave
// PVE-side attribution evidence: the cluster whose storage listed a
// snapshot gets it as LastBackup even though the VMID exists on another
// cluster too, and the other cluster's unconfirmed snapshot is not guessed.
func TestPollStorageBackups_Issue1639HarvestsPBSGuestConfirmations(t *testing.T) {
timeA := time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC)
timeB := time.Date(2026, 7, 27, 2, 0, 0, 0, time.UTC)
state := models.NewState()
state.UpdateVMs([]models.VM{
{VMID: 173, Name: "web", Instance: "cluster-a", Node: "node-a", Status: "running"},
{VMID: 173, Name: "web", Instance: "cluster-b", Node: "node-b", Status: "running"},
})
state.UpdatePBSBackups("pbs-1", []models.PBSBackup{
{ID: "a-173", VMID: "173", BackupType: "vm", BackupTime: timeA, Instance: "pbs-1", Datastore: "backups", Owner: "shared@pbs!token"},
{ID: "b-173", VMID: "173", BackupType: "vm", BackupTime: timeB, Instance: "pbs-1", Datastore: "backups", Owner: "shared@pbs!token"},
})
m := &Monitor{
state: state,
config: &config.Config{
PBSInstances: []config.PBSInstance{{Name: "pbs-1", Host: "https://pbs.example:8007"}},
},
}
client := &pbsCollisionStorageClient{snapshotTime: timeA}
nodes := []pveapi.Node{{Node: "node-a", Status: "online"}}
m.pollStorageBackupsWithNodes(context.Background(), "cluster-a", client, nodes, map[string]string{"node-a": "online"})
snapshot := m.state.GetSnapshot()
if len(snapshot.PVEBackups.StorageBackups) != 0 {
t.Fatalf("pbs-type storage contents must stay out of StorageBackups, got %+v", snapshot.PVEBackups.StorageBackups)
}
for _, vm := range snapshot.VMs {
switch vm.Instance {
case "cluster-a":
if !vm.LastBackup.Equal(timeA) {
t.Errorf("cluster-a VM 173 LastBackup = %v, want confirmed snapshot %v", vm.LastBackup, timeA)
}
case "cluster-b":
if !vm.LastBackup.IsZero() {
t.Errorf("cluster-b VM 173 LastBackup = %v, want zero (its snapshot is unconfirmed)", vm.LastBackup)
}
}
}
}

View file

@ -950,3 +950,48 @@ func TestBuildProxmoxGuestInfoIndex_UsesCanonicalReadState(t *testing.T) {
assertInfo("pve1|nodeA|100", "vm-store-100", "pve1:nodeA:100", unifiedresources.ResourceTypeVM, "vm100")
assertInfo("pve1|nodeB|200", "ct-store-200", "pve1:nodeB:200", unifiedresources.ResourceTypeSystemContainer, "ct200")
}
// TestRetirePVEInstanceRuntimeClearsPBSGuestConfirmations verifies that
// retiring a consolidated PVE instance also drops its PVE-side PBS
// snapshot confirmations (#1639 evidence). Stale confirmations from a
// retired duplicate would make every snapshot it once listed look
// multi-confirmed and degrade collision attribution for the surviving
// instance.
func TestRetirePVEInstanceRuntimeClearsPBSGuestConfirmations(t *testing.T) {
backupTime := time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC)
state := models.NewState()
state.UpdateVMs([]models.VM{
{VMID: 173, Name: "web", Instance: "cluster-a", Node: "node-a", Status: "running"},
{VMID: 173, Name: "web", Instance: "cluster-b", Node: "node-b", Status: "running"},
})
state.UpdatePBSBackups("pbs-1", []models.PBSBackup{
{ID: "a-173", VMID: "173", BackupType: "vm", BackupTime: backupTime, Instance: "pbs-1", Datastore: "backups"},
})
state.UpdatePBSGuestConfirmationsForInstance("cluster-a", []models.PBSGuestConfirmation{
{BackupType: "vm", VMID: 173, Time: backupTime.Unix()},
})
state.SyncGuestBackupTimes()
for _, vm := range state.GetSnapshot().VMs {
if vm.Instance == "cluster-a" && !vm.LastBackup.Equal(backupTime) {
t.Fatalf("precondition: confirmed snapshot should attribute to cluster-a, got %v", vm.LastBackup)
}
}
m := &Monitor{state: state}
m.retirePVEInstanceRuntime("cluster-a")
// Re-add the guest without confirmations: the retired instance's
// evidence must be gone, so the collision VMID is unattributable again.
state.UpdateVMs([]models.VM{
{VMID: 173, Name: "web", Instance: "cluster-a", Node: "node-a", Status: "running"},
{VMID: 173, Name: "web", Instance: "cluster-b", Node: "node-b", Status: "running"},
})
state.SyncGuestBackupTimes()
for _, vm := range state.GetSnapshot().VMs {
if vm.VMID == 173 && !vm.LastBackup.IsZero() {
t.Fatalf("VM 173 on %s should be unattributable after retirement cleared confirmations, got %v", vm.Instance, vm.LastBackup)
}
}
}

View file

@ -159,6 +159,7 @@ func (m *Monitor) retirePVEInstanceRuntime(instanceName string) {
m.state.UpdateCephClustersForInstance(instanceName, []models.CephCluster{})
m.state.UpdateBackupTasksForInstance(instanceName, []models.BackupTask{})
m.state.UpdateStorageBackupsForInstance(instanceName, []models.StorageBackup{})
m.state.UpdatePBSGuestConfirmationsForInstance(instanceName, nil)
m.state.UpdateGuestSnapshotsForInstance(instanceName, []models.GuestSnapshot{})
m.state.UpdateReplicationJobsForInstance(instanceName, []models.ReplicationJob{})
}

View file

@ -88,3 +88,98 @@ func BackupGuestMatchScore(namespace, comment, vmid, guestName, instanceName, no
}
return score
}
// LocationLabelsEqual reports whether two location labels normalize to the
// same non-empty token. Unlike NamespaceMatchesLocation it never suffix
// matches: callers use it when the label is a connection or instance name
// rather than a PBS namespace segment, where loose suffix matching can
// cross-attribute clusters that share a VMID (#1639).
func LocationLabelsEqual(a, b string) bool {
na := normalizeLocationLabel(a)
nb := normalizeLocationLabel(b)
return na != "" && na == nb
}
const (
pbsSourceOwnerKey = "owner"
pbsSourceDatastoreKey = "datastore"
pbsSourceInstanceKey = "instance"
)
// PBSSourceLearner accumulates which PVE connection each PBS submission
// source has been positively attributed to. A "source" is, strongest first,
// the backup owner token, the datastore, and the PBS instance — each scoped
// to the PBS instance the backup came from. Clusters usually push to PBS
// with a per-cluster token and often a per-cluster datastore, so evidence
// learned from unambiguous guests can attribute root-namespace,
// comment-less snapshots whose VMID exists on more than one cluster (#1639).
type PBSSourceLearner struct {
instancesByKey map[string]map[string]struct{}
}
func NewPBSSourceLearner() *PBSSourceLearner {
return &PBSSourceLearner{instancesByKey: make(map[string]map[string]struct{})}
}
func pbsSourceKeys(pbsInstance, datastore, owner string) []string {
pbsInstance = strings.TrimSpace(pbsInstance)
datastore = strings.TrimSpace(datastore)
owner = strings.ToLower(strings.TrimSpace(owner))
keys := make([]string, 0, 3)
if owner != "" {
keys = append(keys, pbsSourceOwnerKey+"\x00"+pbsInstance+"\x00"+owner)
}
if datastore != "" {
keys = append(keys, pbsSourceDatastoreKey+"\x00"+pbsInstance+"\x00"+datastore)
}
if pbsInstance != "" {
keys = append(keys, pbsSourceInstanceKey+"\x00"+pbsInstance)
}
return keys
}
// Observe records that a backup from the given PBS source was positively
// attributed to a PVE connection (via a unique VMID, namespace placement,
// or guest-name match).
func (l *PBSSourceLearner) Observe(pbsInstance, datastore, owner, pveInstance string) {
if l == nil {
return
}
pveInstance = strings.TrimSpace(pveInstance)
if pveInstance == "" {
return
}
for _, key := range pbsSourceKeys(pbsInstance, datastore, owner) {
set, ok := l.instancesByKey[key]
if !ok {
set = make(map[string]struct{})
l.instancesByKey[key] = set
}
set[pveInstance] = struct{}{}
}
}
// Resolve reports the single PVE connection the backup's source evidence
// identifies. Evidence is consulted strongest-first; a source component that
// maps to several connections is not a discriminator and defers to the next
// one, but a component that was never observed stops resolution entirely —
// an unfamiliar owner or datastore means the backup may belong to a cluster
// we have no evidence for, and guessing from weaker components would
// attribute it to the wrong cluster.
func (l *PBSSourceLearner) Resolve(pbsInstance, datastore, owner string) (string, bool) {
if l == nil {
return "", false
}
for _, key := range pbsSourceKeys(pbsInstance, datastore, owner) {
set, ok := l.instancesByKey[key]
if !ok {
return "", false
}
if len(set) == 1 {
for instance := range set {
return instance, true
}
}
}
return "", false
}

View file

@ -69,3 +69,66 @@ func TestBackupGuestMatchScoreRanksNodeMatchAboveClusterEntrypoint(t *testing.T)
t.Fatalf("node namespace score = %d, should outrank weak instance+comment match", got)
}
}
func TestLocationLabelsEqualRequiresExactNormalizedMatch(t *testing.T) {
if !LocationLabelsEqual("pve-nat", "PVE Nat") {
t.Fatal("normalized identical labels should match")
}
// NamespaceMatchesLocation would suffix-match these; a connection label
// must not (#1639).
if LocationLabelsEqual("nat", "pve-nat") {
t.Fatal("suffix relation between distinct labels must not match")
}
if LocationLabelsEqual("", "") {
t.Fatal("empty labels must not match")
}
}
func TestPBSSourceLearnerResolvesOwnerBeforeWeakerComponents(t *testing.T) {
l := NewPBSSourceLearner()
l.Observe("pbs-main", "backups", "cluster-a@pbs!token", "cluster-a")
l.Observe("pbs-main", "backups", "cluster-b@pbs!token", "cluster-b")
// Datastore and PBS instance are shared, but each owner token uniquely
// identifies its cluster.
if inst, ok := l.Resolve("pbs-main", "backups", "cluster-a@pbs!token"); !ok || inst != "cluster-a" {
t.Fatalf("owner resolution = %q,%v, want cluster-a,true", inst, ok)
}
if inst, ok := l.Resolve("pbs-main", "backups", "CLUSTER-B@pbs!token"); !ok || inst != "cluster-b" {
t.Fatalf("owner resolution should be case-insensitive, got %q,%v", inst, ok)
}
}
func TestPBSSourceLearnerSharedSourceIsNotDecisive(t *testing.T) {
l := NewPBSSourceLearner()
l.Observe("pbs-main", "backups", "shared@pbs!token", "cluster-a")
l.Observe("pbs-main", "backups", "shared@pbs!token", "cluster-b")
if inst, ok := l.Resolve("pbs-main", "backups", "shared@pbs!token"); ok {
t.Fatalf("shared source resolved to %q, want inconclusive", inst)
}
}
func TestPBSSourceLearnerUnfamiliarComponentStopsResolution(t *testing.T) {
l := NewPBSSourceLearner()
l.Observe("pbs-main", "backups", "cluster-a@pbs!token", "cluster-a")
// The datastore alone would resolve to cluster-a, but an owner token
// that was never observed means the backup may come from a cluster we
// have no evidence for — weaker components must not be consulted.
if inst, ok := l.Resolve("pbs-main", "backups", "mystery@pbs!token"); ok {
t.Fatalf("unfamiliar owner resolved to %q, want inconclusive", inst)
}
// Without an owner on the backup, the datastore evidence applies.
if inst, ok := l.Resolve("pbs-main", "backups", ""); !ok || inst != "cluster-a" {
t.Fatalf("datastore resolution = %q,%v, want cluster-a,true", inst, ok)
}
}
func TestPBSSourceLearnerNilReceiverIsInert(t *testing.T) {
var l *PBSSourceLearner
l.Observe("pbs-main", "backups", "owner", "cluster-a")
if _, ok := l.Resolve("pbs-main", "backups", "owner"); ok {
t.Fatal("nil learner must never be decisive")
}
}

View file

@ -53,7 +53,7 @@ func preferredPBSBackupSubjectName(comment, vmid string) string {
return proxmoxidentity.PreferredPBSBackupSubjectName(comment, vmid)
}
func selectPBSGuestCandidate(backup models.PBSBackup, candidates []GuestCandidate) (GuestCandidate, bool) {
func selectPBSGuestCandidate(backup models.PBSBackup, candidates []GuestCandidate, learner *proxmoxidentity.PBSSourceLearner) (GuestCandidate, bool) {
if len(candidates) == 0 {
return GuestCandidate{}, false
}
@ -92,6 +92,30 @@ func selectPBSGuestCandidate(backup models.PBSBackup, candidates []GuestCandidat
}
}
// Last resort for collision VMIDs with no namespace or name evidence:
// the learned submission-source mapping (owner token / datastore / PBS
// instance, learned from this batch's attributable backups) can name the
// cluster the backup came from (#1639). A decisive attribution to a
// connection with no candidate here means the backup's own guest is
// gone; never link it to another cluster's guest.
if learner != nil {
if attributed, decisive := learner.Resolve(backup.Instance, backup.Datastore, backup.Owner); decisive {
filtered := make([]GuestCandidate, 0, len(matched))
for _, candidate := range matched {
if candidate.InstanceName == attributed {
filtered = append(filtered, candidate)
}
}
if len(filtered) == 1 {
return filtered[0], true
}
if len(filtered) == 0 {
return GuestCandidate{}, false
}
matched = filtered
}
}
if len(matched) == 1 {
return matched[0], true
}
@ -380,6 +404,20 @@ func FromPBSBackups(backups []models.PBSBackup, candidatesByKey map[string][]Gue
return nil
}
// First pass: learn each PBS submission source's cluster from the
// backups that are attributable on their own evidence, so the second
// pass can resolve collision VMIDs with no evidence of their own (#1639).
learner := proxmoxidentity.NewPBSSourceLearner()
for _, b := range backups {
if strings.TrimSpace(b.ID) == "" {
continue
}
key := strings.ToLower(strings.TrimSpace(b.BackupType)) + ":" + strings.TrimSpace(b.VMID)
if c, ok := selectPBSGuestCandidate(b, candidatesByKey[key], nil); ok {
learner.Observe(b.Instance, b.Datastore, b.Owner, c.InstanceName)
}
}
out := make([]recovery.RecoveryPoint, 0, len(backups))
for _, b := range backups {
if strings.TrimSpace(b.ID) == "" {
@ -395,7 +433,7 @@ func FromPBSBackups(backups []models.PBSBackup, candidatesByKey map[string][]Gue
// Link to a unified resource when the candidate set is already singular or can be
// disambiguated by PBS namespace / guest label without guessing across guest collisions.
if c, ok := selectPBSGuestCandidate(b, candidates); ok {
if c, ok := selectPBSGuestCandidate(b, candidates, learner); ok {
subjectRID = subjectResourceID(c.ResourceType, c.ResourceID, c.SourceID)
subjectRef = proxmoxSubjectRef(c.ResourceType, GuestInfo{Name: c.DisplayName, ResourceType: c.ResourceType, SourceID: c.SourceID}, c.InstanceName, c.NodeName, c.VMID, c.SourceID)
} else {

View file

@ -510,3 +510,100 @@ func TestFromPBSBackupsWithEvidenceAddsProviderScopeAndCorrelation(t *testing.T)
t.Fatalf("evidence Validate() error = %v", err)
}
}
// Issue #1639: a root-namespace comment-less snapshot whose VMID exists on
// two clusters must link to the cluster its submission source (owner token,
// datastore, PBS instance) belongs to, learned from the batch's
// attributable backups — and must stay unlinked when the source is shared.
func TestFromPBSBackups_DisambiguatesCollisionVMIDBySubmissionSource(t *testing.T) {
candidatesByKey := map[string][]GuestCandidate{
"vm:173": {
{
ResourceID: "vm-aaaaaaaaaaaaaaaa",
SourceID: "cluster-a:pve-a1:173",
ResourceType: unifiedresources.ResourceTypeVM,
DisplayName: "web-a",
InstanceName: "cluster-a",
NodeName: "pve-a1",
VMID: 173,
BackupTypeKey: "vm",
},
{
ResourceID: "vm-bbbbbbbbbbbbbbbb",
SourceID: "cluster-b:pve-b1:173",
ResourceType: unifiedresources.ResourceTypeVM,
DisplayName: "web-b",
InstanceName: "cluster-b",
NodeName: "pve-b1",
VMID: 173,
BackupTypeKey: "vm",
},
},
"vm:100": {
{
ResourceID: "vm-cccccccccccccccc",
SourceID: "cluster-a:pve-a1:100",
ResourceType: unifiedresources.ResourceTypeVM,
DisplayName: "db-a",
InstanceName: "cluster-a",
NodeName: "pve-a1",
VMID: 100,
BackupTypeKey: "vm",
},
},
}
backups := []models.PBSBackup{
// Teaching snapshot: unique VMID attributes cluster-a's owner token.
{
ID: "pbs-a-100",
VMID: "100",
Instance: "pbs-main",
Datastore: "store-a",
Owner: "cluster-a@pbs!token",
BackupType: "vm",
BackupTime: time.Date(2026, 7, 26, 1, 0, 0, 0, time.UTC),
},
// Collision snapshot: no namespace, no comment, cluster-a's source.
{
ID: "pbs-a-173",
VMID: "173",
Instance: "pbs-main",
Datastore: "store-a",
Owner: "cluster-a@pbs!token",
BackupType: "vm",
BackupTime: time.Date(2026, 7, 27, 1, 0, 0, 0, time.UTC),
},
// Collision snapshot from a source never attributed to any cluster:
// must stay unlinked rather than guess.
{
ID: "pbs-x-173",
VMID: "173",
Instance: "pbs-main",
Datastore: "store-x",
Owner: "mystery@pbs!token",
BackupType: "vm",
BackupTime: time.Date(2026, 7, 27, 2, 0, 0, 0, time.UTC),
},
}
result := FromPBSBackups(backups, candidatesByKey)
if len(result) != 3 {
t.Fatalf("expected 3 points, got %d", len(result))
}
byID := map[string]int{}
for i, p := range result {
byID[p.ID] = i
}
linked := result[byID["pbs-backup:pbs-a-173"]]
if linked.SubjectResourceID != "vm-aaaaaaaaaaaaaaaa" {
t.Fatalf("collision snapshot SubjectResourceID = %q, want cluster-a guest", linked.SubjectResourceID)
}
unlinked := result[byID["pbs-backup:pbs-x-173"]]
if unlinked.SubjectResourceID != "" {
t.Fatalf("unattributable snapshot linked to %q, want unlinked", unlinked.SubjectResourceID)
}
}