From d704afa68abeca49699f890f9fa81196f55084ab Mon Sep 17 00:00:00 2001 From: "pulse-triage[bot]" <249995291+pulse-triage[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 04:32:02 +0100 Subject: [PATCH] Reject shared-node PBS ties despite learned source A globally observed second cluster can leave a PBS source apparently mapped to the first while both clusters match a shared-node snapshot. Do not promote a positive but tied guest match into the VMID-only inference path. Regression covers both VM and CT completed and in-flight snapshots with the second cluster visible on another PBS instance. Contract-Neutral: Preserve existing PBS backup attribution safety without changing API fields, persisted shapes, or source contracts. Change-source: pulse-maintainer --- internal/models/issue2292_backup_age_test.go | 28 +++++++++++++++++--- internal/models/models.go | 10 +++---- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/internal/models/issue2292_backup_age_test.go b/internal/models/issue2292_backup_age_test.go index 56c91d41a0..ad4993e674 100644 --- a/internal/models/issue2292_backup_age_test.go +++ b/internal/models/issue2292_backup_age_test.go @@ -98,11 +98,15 @@ func TestSyncGuestBackupTimesNewestPBSBackupPreservesCollisionGuard(t *testing.T got := make(map[string]time.Time) if backupType == "vm" { for _, guest := range snapshot.VMs { - got[guest.Instance] = guest.LastBackup + if guest.VMID == 112 { + got[guest.Instance] = guest.LastBackup + } } } else { for _, guest := range snapshot.Containers { - got[guest.Instance] = guest.LastBackup + if guest.VMID == 112 { + got[guest.Instance] = guest.LastBackup + } } } if !got["cluster-a"].Equal(recentA) { @@ -132,11 +136,13 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) { state.UpdateVMs([]VM{ {VMID: 112, Name: "guest-a", Instance: "cluster-a", Node: "pve"}, {VMID: 112, Name: "guest-b", Instance: "cluster-b", Node: "pve"}, + {VMID: 113, Name: "other-b", Instance: "cluster-b", Node: "pve"}, }) } else { state.UpdateContainers([]Container{ {VMID: 112, Name: "guest-a", Instance: "cluster-a", Node: "pve"}, {VMID: 112, Name: "guest-b", Instance: "cluster-b", Node: "pve"}, + {VMID: 113, Name: "other-b", Instance: "cluster-b", Node: "pve"}, }) } @@ -150,6 +156,10 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) { // Both guests match node pve, but guest-b is the stronger match. {ID: "recent-b", VMID: "112", BackupType: backupType, BackupTime: recentB, Instance: "pbs-main", Namespace: "pve", Comment: "guest-b"}, + // B is visible on a different PBS instance. That is not proof + // that pbs-main's tied snapshot belongs to A rather than B. + {ID: "other-b", VMID: "113", BackupType: backupType, BackupTime: recentB, + Instance: "pbs-other"}, // Neither guest owns an otherwise indistinguishable newer copy. {ID: "shared-node", VMID: "112", BackupType: backupType, BackupTime: now.Add(-time.Hour), Instance: "pbs-main", Namespace: "pve"}, @@ -167,11 +177,15 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) { got := make(map[string]time.Time) if backupType == "vm" { for _, guest := range snapshot.VMs { - got[guest.Instance] = guest.LastBackup + if guest.VMID == 112 { + got[guest.Instance] = guest.LastBackup + } } } else { for _, guest := range snapshot.Containers { - got[guest.Instance] = guest.LastBackup + if guest.VMID == 112 { + got[guest.Instance] = guest.LastBackup + } } } if !got["cluster-a"].Equal(recentA) { @@ -182,12 +196,18 @@ func TestSyncGuestBackupTimesNewestPBSBackupSameNodeCollision(t *testing.T) { } if backupType == "vm" { for _, guest := range snapshot.VMs { + if guest.VMID != 112 { + continue + } if guest.BackupInProgress != (guest.Instance == "cluster-b") { t.Errorf("%s BackupInProgress = %v, want only cluster-b running", guest.Instance, guest.BackupInProgress) } } } else { for _, guest := range snapshot.Containers { + if guest.VMID != 112 { + continue + } if guest.BackupInProgress != (guest.Instance == "cluster-b") { t.Errorf("%s BackupInProgress = %v, want only cluster-b running", guest.Instance, guest.BackupInProgress) } diff --git a/internal/models/models.go b/internal/models/models.go index 359f242c1e..46d9e0e728 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -4745,8 +4745,9 @@ func (s *State) SyncGuestBackupTimes() { // A namespace such as "pve" can match two clusters with the // same node label. A positive score is not attribution when a // different connection matches the same snapshot at least as - // strongly. A stronger competitor rules this guest out; a tie - // needs independent storage/source evidence below (#2292). + // strongly. Do not use batch-learned source inference to break a + // positive tie: it can see the other connection on another PBS + // instance without proving which one wrote this snapshot (#2292). strongestOther := 0 for _, other := range subjectGuests[subjectKey] { if other.instance == instance { @@ -4760,12 +4761,9 @@ func (s *State) SyncGuestBackupTimes() { strongestOther = otherScore } } - if strongestOther > score { + if strongestOther >= score { continue } - if strongestOther == score { - score = 0 - } } if score == 0 { if !subjectIsAmbiguous[subjectKey] {