diff --git a/internal/alerts/alerts.go b/internal/alerts/alerts.go index e6783934f..bf047dbc4 100644 --- a/internal/alerts/alerts.go +++ b/internal/alerts/alerts.go @@ -5507,6 +5507,43 @@ func BuildGuestKey(instance, node string, vmid int) string { return fmt.Sprintf("%s:%s:%d", instance, node, vmid) } +func canonicalBackupGuestType(kind string) string { + switch strings.ToLower(strings.TrimSpace(kind)) { + case "vm", "qemu": + return "vm" + case "ct", "lxc", "container": + return "ct" + default: + return "" + } +} + +func guestMatchesBackupType(guest GuestLookup, backupType string) bool { + backupKind := canonicalBackupGuestType(backupType) + if backupKind == "" { + return true + } + guestKind := canonicalBackupGuestType(guest.Type) + if guestKind == "" { + return false + } + return guestKind == backupKind +} + +func filterGuestsByBackupType(guests []GuestLookup, backupType string) []GuestLookup { + if canonicalBackupGuestType(backupType) == "" { + return guests + } + + filtered := make([]GuestLookup, 0, len(guests)) + for _, guest := range guests { + if guestMatchesBackupType(guest, backupType) { + filtered = append(filtered, guest) + } + } + return filtered +} + func isGuestMetricResourceType(resourceType string) bool { switch strings.TrimSpace(resourceType) { case "VM", "Container": @@ -5961,6 +5998,9 @@ func (m *Manager) CheckBackups( vmid = strconv.Itoa(backup.VMID) } info := guestsByKey[key] + if !guestMatchesBackupType(info, backup.Type) { + info = GuestLookup{} + } displayName := info.Name if displayName == "" { displayName = fmt.Sprintf("%s-%d", sanitizeAlertKey(backup.Node), backup.VMID) @@ -5998,13 +6038,14 @@ func (m *Manager) CheckBackups( var node string if exists && len(guests) > 0 { + typedGuests := filterGuestsByBackupType(guests, backup.BackupType) // If we have exactly one match, use it directly // If we have multiple matches, try to disambiguate using the PBS namespace - if len(guests) == 1 { - info = guests[0] + if len(typedGuests) == 1 { + info = typedGuests[0] } else if backup.Namespace != "" { // Try to match namespace to instance name - for _, g := range guests { + for _, g := range typedGuests { if namespaceMatchesInstance(backup.Namespace, g.Instance) { info = g break @@ -6177,6 +6218,9 @@ func (m *Manager) CheckBackups( if g.ResourceID == "" { continue } + if !guestMatchesBackupType(g, record.backupType) { + continue + } if record.source == "PVE storage" && g.Instance != record.instance { continue } @@ -6185,7 +6229,7 @@ func (m *Manager) CheckBackups( } } if !existsInInventory { - if g, ok := guestsByKey[record.key]; ok && g.ResourceID != "" { + if g, ok := guestsByKey[record.key]; ok && g.ResourceID != "" && guestMatchesBackupType(g, record.backupType) { existsInInventory = true } } diff --git a/internal/alerts/alerts_test.go b/internal/alerts/alerts_test.go index 5510cf57e..df0b51b8a 100644 --- a/internal/alerts/alerts_test.go +++ b/internal/alerts/alerts_test.go @@ -1436,6 +1436,135 @@ func TestCheckBackupsVMIDCollisionNoNamespace(t *testing.T) { } } +func TestCheckBackupsPbsTypeMismatchCreatesOrphanedAlert(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() + pbsBackups := []models.PBSBackup{ + { + ID: "pbs-vm-101", + Instance: "pbs-main", + Datastore: "backup-store", + BackupType: "vm", + VMID: "101", + BackupTime: now.Add(-30 * 24 * time.Hour), + }, + } + + guestKey := BuildGuestKey("pve1", "node1", 101) + guestsByKey := map[string]GuestLookup{ + guestKey: { + ResourceID: "lxc/101", + Name: "ct-101", + Instance: "pve1", + Node: "node1", + Type: "lxc", + VMID: 101, + }, + } + guestsByVMID := map[string][]GuestLookup{ + "101": {guestsByKey[guestKey]}, + } + + m.CheckBackups(nil, pbsBackups, nil, guestsByKey, guestsByVMID, nil) + + orphanedID := "backup-orphaned-" + sanitizeAlertKey("pbs:pbs-main:vm:101") + ageID := "backup-age-" + sanitizeAlertKey("pbs:pbs-main:vm:101") + + m.mu.RLock() + defer m.mu.RUnlock() + + alert, exists := m.activeAlerts[orphanedID] + if !exists { + var keys []string + for k := range m.activeAlerts { + keys = append(keys, k) + } + t.Fatalf("expected orphaned alert %q, found keys: %v", orphanedID, keys) + } + if alert.Type != "backup-orphaned" { + t.Fatalf("expected backup-orphaned alert, got %s", alert.Type) + } + if _, exists := m.activeAlerts[ageID]; exists { + t.Fatalf("expected no backup-age alert for mismatched live guest type") + } +} + +func TestCheckBackupsStorageTypeMismatchCreatesOrphanedAlert(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() + storageBackups := []models.StorageBackup{ + { + ID: "pve1-node1-101-backup", + Storage: "local", + Node: "node1", + Instance: "pve1", + Type: "qemu", + VMID: 101, + Time: now.Add(-30 * 24 * time.Hour), + }, + } + + guestKey := BuildGuestKey("pve1", "node1", 101) + guestsByKey := map[string]GuestLookup{ + guestKey: { + ResourceID: "lxc/101", + Name: "ct-101", + Instance: "pve1", + Node: "node1", + Type: "lxc", + VMID: 101, + }, + } + guestsByVMID := map[string][]GuestLookup{ + "101": {guestsByKey[guestKey]}, + } + + m.CheckBackups(storageBackups, nil, nil, guestsByKey, guestsByVMID, nil) + + orphanedID := "backup-orphaned-" + sanitizeAlertKey(guestKey) + ageID := "backup-age-" + sanitizeAlertKey(guestKey) + + m.mu.RLock() + defer m.mu.RUnlock() + + alert, exists := m.activeAlerts[orphanedID] + if !exists { + var keys []string + for k := range m.activeAlerts { + keys = append(keys, k) + } + t.Fatalf("expected orphaned alert %q, found keys: %v", orphanedID, keys) + } + if alert.Type != "backup-orphaned" { + t.Fatalf("expected backup-orphaned alert, got %s", alert.Type) + } + if _, exists := m.activeAlerts[ageID]; exists { + t.Fatalf("expected no backup-age alert for mismatched live guest type") + } +} + func TestCheckBackupsHandlesPmgBackups(t *testing.T) { m := newTestManager(t) m.ClearActiveAlerts()