Pulse/internal/models/state_host_test.go
rcourtman dcfa8cf0ba fix: prevent false PBS backup indicators when VMIDs collide across PVE instances (#1177)
When namespace matching fails, the VMID-only fallback now checks whether
the VMID appears on multiple PVE instances. If ambiguous, the fallback
is skipped — preventing backups from being falsely attributed to the
wrong guest. Unique VMIDs still fall back as before.
2026-02-04 10:11:35 +00:00

1166 lines
33 KiB
Go

package models
import (
"testing"
"time"
)
func TestUpsertHost(t *testing.T) {
state := NewState()
// Test insert new host
host1 := Host{
ID: "host-1",
Hostname: "server-1",
Status: "online",
}
state.UpsertHost(host1)
hosts := state.GetHosts()
if len(hosts) != 1 {
t.Fatalf("Expected 1 host, got %d", len(hosts))
}
if hosts[0].ID != "host-1" {
t.Errorf("Expected host ID 'host-1', got %q", hosts[0].ID)
}
// Test update existing host
host1Updated := Host{
ID: "host-1",
Hostname: "server-1",
Status: "offline",
}
state.UpsertHost(host1Updated)
hosts = state.GetHosts()
if len(hosts) != 1 {
t.Fatalf("Expected 1 host after update, got %d", len(hosts))
}
if hosts[0].Status != "offline" {
t.Errorf("Expected status 'offline', got %q", hosts[0].Status)
}
// Test multiple hosts and sorting
state.UpsertHost(Host{ID: "host-2", Hostname: "alpha-server"})
state.UpsertHost(Host{ID: "host-3", Hostname: "zulu-server"})
hosts = state.GetHosts()
if len(hosts) != 3 {
t.Fatalf("Expected 3 hosts, got %d", len(hosts))
}
// Hosts should be sorted by hostname
if hosts[0].Hostname != "alpha-server" {
t.Errorf("First host should be 'alpha-server', got %q", hosts[0].Hostname)
}
}
func TestGetHosts_Copy(t *testing.T) {
state := NewState()
state.UpsertHost(Host{ID: "host-1", Hostname: "server-1"})
hosts1 := state.GetHosts()
hosts2 := state.GetHosts()
// Modify one slice
if len(hosts1) > 0 {
hosts1[0].Hostname = "modified"
}
// Other slice should be unchanged
if len(hosts2) > 0 && hosts2[0].Hostname == "modified" {
t.Error("GetHosts should return a copy, not the same slice")
}
// Original state should be unchanged
hosts3 := state.GetHosts()
if hosts3[0].Hostname == "modified" {
t.Error("State should be unchanged by modifications to returned slice")
}
}
func TestRemoveHost(t *testing.T) {
state := NewState()
// Insert hosts
state.UpsertHost(Host{ID: "host-1", Hostname: "server1"})
state.UpsertHost(Host{ID: "host-2", Hostname: "server2"})
// Remove existing host
removed, ok := state.RemoveHost("host-1")
if !ok {
t.Error("Expected RemoveHost to return true for existing host")
}
if removed.ID != "host-1" {
t.Errorf("Expected removed host ID 'host-1', got %q", removed.ID)
}
hosts := state.GetHosts()
if len(hosts) != 1 {
t.Fatalf("Expected 1 host after removal, got %d", len(hosts))
}
if hosts[0].ID != "host-2" {
t.Errorf("Remaining host should be 'host-2', got %q", hosts[0].ID)
}
// Remove non-existing host
removed, ok = state.RemoveHost("non-existent")
if ok {
t.Error("Expected RemoveHost to return false for non-existent host")
}
if removed.ID != "" {
t.Errorf("Expected empty Host for non-existent removal, got ID %q", removed.ID)
}
}
func TestSetHostStatus(t *testing.T) {
state := NewState()
// Test with no hosts
changed := state.SetHostStatus("host-1", "online")
if changed {
t.Error("SetHostStatus should return false when host doesn't exist")
}
// Add host and set status
state.UpsertHost(Host{ID: "host-1", Hostname: "server1", Status: "offline"})
changed = state.SetHostStatus("host-1", "online")
if !changed {
t.Error("SetHostStatus should return true when host exists")
}
hosts := state.GetHosts()
if hosts[0].Status != "online" {
t.Errorf("Expected status 'online', got %q", hosts[0].Status)
}
// Set same status (no change)
changed = state.SetHostStatus("host-1", "online")
if !changed {
t.Error("SetHostStatus should return true even when status unchanged")
}
}
func TestTouchHost(t *testing.T) {
state := NewState()
now := time.Now()
// Test with non-existent host
ok := state.TouchHost("host-1", now)
if ok {
t.Error("TouchHost should return false for non-existent host")
}
// Add host and touch it
state.UpsertHost(Host{ID: "host-1", Hostname: "server1"})
later := now.Add(time.Hour)
ok = state.TouchHost("host-1", later)
if !ok {
t.Error("TouchHost should return true for existing host")
}
hosts := state.GetHosts()
if !hosts[0].LastSeen.Equal(later) {
t.Errorf("LastSeen should be updated to %v, got %v", later, hosts[0].LastSeen)
}
}
func TestUpdateRecentlyResolved(t *testing.T) {
state := NewState()
now := time.Now()
alerts := []ResolvedAlert{
{
Alert: Alert{
ID: "alert-1",
Type: "cpu_high",
Level: "warning",
ResourceName: "server-1",
Message: "High CPU usage",
},
ResolvedTime: now,
},
{
Alert: Alert{
ID: "alert-2",
Type: "disk_low",
Level: "critical",
ResourceName: "server-2",
Message: "Low disk space",
},
ResolvedTime: now.Add(-time.Hour),
},
}
state.UpdateRecentlyResolved(alerts)
// The method updates internal state - verify via snapshot or other means
// Since RecentlyResolvedAlerts might be accessed differently, just verify no panic
// and method executes correctly
}
func TestSetConnectionHealth(t *testing.T) {
state := NewState()
// Set connection healthy
state.SetConnectionHealth("pve-cluster-1", true)
snapshot := state.GetSnapshot()
if healthy, ok := snapshot.ConnectionHealth["pve-cluster-1"]; !ok || !healthy {
t.Error("Expected connection to be healthy")
}
// Set connection unhealthy
state.SetConnectionHealth("pve-cluster-1", false)
snapshot = state.GetSnapshot()
if healthy, ok := snapshot.ConnectionHealth["pve-cluster-1"]; !ok || healthy {
t.Error("Expected connection to be unhealthy")
}
// Multiple connections
state.SetConnectionHealth("pve-cluster-2", true)
state.SetConnectionHealth("pbs-instance-1", true)
snapshot = state.GetSnapshot()
if len(snapshot.ConnectionHealth) != 3 {
t.Errorf("Expected 3 connection health entries, got %d", len(snapshot.ConnectionHealth))
}
}
func TestRemoveConnectionHealth(t *testing.T) {
state := NewState()
// Set up connection health entries
state.SetConnectionHealth("pve-cluster-1", true)
state.SetConnectionHealth("pve-cluster-2", false)
// Remove one
state.RemoveConnectionHealth("pve-cluster-1")
snapshot := state.GetSnapshot()
if _, ok := snapshot.ConnectionHealth["pve-cluster-1"]; ok {
t.Error("Expected pve-cluster-1 to be removed")
}
if _, ok := snapshot.ConnectionHealth["pve-cluster-2"]; !ok {
t.Error("Expected pve-cluster-2 to still exist")
}
// Remove non-existent (should not panic)
state.RemoveConnectionHealth("non-existent")
// Remove remaining
state.RemoveConnectionHealth("pve-cluster-2")
snapshot = state.GetSnapshot()
if len(snapshot.ConnectionHealth) != 0 {
t.Errorf("Expected empty connection health map, got %d entries", len(snapshot.ConnectionHealth))
}
}
func TestUpdatePBSBackups(t *testing.T) {
state := NewState()
now := time.Now()
// Add backups from first instance
backups1 := []PBSBackup{
{ID: "backup-1", Instance: "pbs-1", BackupTime: now},
{ID: "backup-2", Instance: "pbs-1", BackupTime: now.Add(-time.Hour)},
}
state.UpdatePBSBackups("pbs-1", backups1)
snapshot := state.GetSnapshot()
if len(snapshot.PBSBackups) != 2 {
t.Fatalf("Expected 2 backups, got %d", len(snapshot.PBSBackups))
}
// Add backups from second instance
backups2 := []PBSBackup{
{ID: "backup-3", Instance: "pbs-2", BackupTime: now.Add(-30 * time.Minute)},
}
state.UpdatePBSBackups("pbs-2", backups2)
snapshot = state.GetSnapshot()
if len(snapshot.PBSBackups) != 3 {
t.Fatalf("Expected 3 backups, got %d", len(snapshot.PBSBackups))
}
// Update first instance (should replace its backups)
backups1Updated := []PBSBackup{
{ID: "backup-4", Instance: "pbs-1", BackupTime: now.Add(time.Hour)},
}
state.UpdatePBSBackups("pbs-1", backups1Updated)
snapshot = state.GetSnapshot()
if len(snapshot.PBSBackups) != 2 {
t.Fatalf("Expected 2 backups after update, got %d", len(snapshot.PBSBackups))
}
// Verify pbs-1 backups were replaced
hasOldBackup := false
for _, b := range snapshot.PBSBackups {
if b.ID == "backup-1" || b.ID == "backup-2" {
hasOldBackup = true
break
}
}
if hasOldBackup {
t.Error("Old pbs-1 backups should have been replaced")
}
}
func TestUpdatePMGBackups(t *testing.T) {
state := NewState()
now := time.Now()
// Add backups from first instance
backups1 := []PMGBackup{
{Filename: "backup-1.conf", Instance: "pmg-1", BackupTime: now},
{Filename: "backup-2.conf", Instance: "pmg-1", BackupTime: now.Add(-time.Hour)},
}
state.UpdatePMGBackups("pmg-1", backups1)
snapshot := state.GetSnapshot()
if len(snapshot.PMGBackups) != 2 {
t.Fatalf("Expected 2 backups, got %d", len(snapshot.PMGBackups))
}
// Backups should be sorted by time (newest first)
if snapshot.PMGBackups[0].BackupTime.Before(snapshot.PMGBackups[1].BackupTime) {
t.Error("Backups should be sorted by time descending")
}
// Add backups from second instance
backups2 := []PMGBackup{
{Filename: "backup-3.conf", Instance: "pmg-2", BackupTime: now.Add(-30 * time.Minute)},
}
state.UpdatePMGBackups("pmg-2", backups2)
snapshot = state.GetSnapshot()
if len(snapshot.PMGBackups) != 3 {
t.Fatalf("Expected 3 backups, got %d", len(snapshot.PMGBackups))
}
// Update first instance with empty (should remove its backups)
state.UpdatePMGBackups("pmg-1", []PMGBackup{})
snapshot = state.GetSnapshot()
if len(snapshot.PMGBackups) != 1 {
t.Fatalf("Expected 1 backup after clearing pmg-1, got %d", len(snapshot.PMGBackups))
}
if snapshot.PMGBackups[0].Instance != "pmg-2" {
t.Error("Remaining backup should be from pmg-2")
}
}
func TestUpdatePhysicalDisks(t *testing.T) {
state := NewState()
// Add disks from first instance
disks1 := []PhysicalDisk{
{ID: "disk-1", Instance: "pve-1", Node: "node1", DevPath: "/dev/sda"},
{ID: "disk-2", Instance: "pve-1", Node: "node1", DevPath: "/dev/sdb"},
}
state.UpdatePhysicalDisks("pve-1", disks1)
snapshot := state.GetSnapshot()
if len(snapshot.PhysicalDisks) != 2 {
t.Fatalf("Expected 2 disks, got %d", len(snapshot.PhysicalDisks))
}
// Add disks from second instance
disks2 := []PhysicalDisk{
{ID: "disk-3", Instance: "pve-2", Node: "node2", DevPath: "/dev/sda"},
}
state.UpdatePhysicalDisks("pve-2", disks2)
snapshot = state.GetSnapshot()
if len(snapshot.PhysicalDisks) != 3 {
t.Fatalf("Expected 3 disks, got %d", len(snapshot.PhysicalDisks))
}
// Update first instance (should replace its disks)
disks1Updated := []PhysicalDisk{
{ID: "disk-4", Instance: "pve-1", Node: "node1", DevPath: "/dev/nvme0n1"},
}
state.UpdatePhysicalDisks("pve-1", disks1Updated)
snapshot = state.GetSnapshot()
if len(snapshot.PhysicalDisks) != 2 {
t.Fatalf("Expected 2 disks after update, got %d", len(snapshot.PhysicalDisks))
}
// Verify sorting by node then devpath
if snapshot.PhysicalDisks[0].Node > snapshot.PhysicalDisks[1].Node {
t.Error("Disks should be sorted by node")
}
}
func TestUpdateStorageForInstance(t *testing.T) {
state := NewState()
// Add storage from first instance
storage1 := []Storage{
{ID: "storage-1", Instance: "pve-1", Name: "local"},
{ID: "storage-2", Instance: "pve-1", Name: "ceph-pool"},
}
state.UpdateStorageForInstance("pve-1", storage1)
snapshot := state.GetSnapshot()
if len(snapshot.Storage) != 2 {
t.Fatalf("Expected 2 storage entries, got %d", len(snapshot.Storage))
}
// Add storage from second instance
storage2 := []Storage{
{ID: "storage-3", Instance: "pve-2", Name: "local"},
}
state.UpdateStorageForInstance("pve-2", storage2)
snapshot = state.GetSnapshot()
if len(snapshot.Storage) != 3 {
t.Fatalf("Expected 3 storage entries, got %d", len(snapshot.Storage))
}
// Update first instance with empty (should remove its storage)
state.UpdateStorageForInstance("pve-1", []Storage{})
snapshot = state.GetSnapshot()
if len(snapshot.Storage) != 1 {
t.Fatalf("Expected 1 storage after clearing pve-1, got %d", len(snapshot.Storage))
}
}
func TestUpdatePBSInstances(t *testing.T) {
state := NewState()
instances := []PBSInstance{
{ID: "pbs-1", Name: "Backup Server 1"},
{ID: "pbs-2", Name: "Backup Server 2"},
}
state.UpdatePBSInstances(instances)
snapshot := state.GetSnapshot()
if len(snapshot.PBSInstances) != 2 {
t.Fatalf("Expected 2 PBS instances, got %d", len(snapshot.PBSInstances))
}
// Replace with different instances
newInstances := []PBSInstance{
{ID: "pbs-3", Name: "New Backup Server"},
}
state.UpdatePBSInstances(newInstances)
snapshot = state.GetSnapshot()
if len(snapshot.PBSInstances) != 1 {
t.Fatalf("Expected 1 PBS instance after replacement, got %d", len(snapshot.PBSInstances))
}
if snapshot.PBSInstances[0].ID != "pbs-3" {
t.Error("Expected pbs-3 instance")
}
}
func TestUpdatePBSInstance(t *testing.T) {
state := NewState()
// Add first instance
state.UpdatePBSInstance(PBSInstance{ID: "pbs-1", Name: "Server 1"})
snapshot := state.GetSnapshot()
if len(snapshot.PBSInstances) != 1 {
t.Fatalf("Expected 1 PBS instance, got %d", len(snapshot.PBSInstances))
}
// Add second instance
state.UpdatePBSInstance(PBSInstance{ID: "pbs-2", Name: "Server 2"})
snapshot = state.GetSnapshot()
if len(snapshot.PBSInstances) != 2 {
t.Fatalf("Expected 2 PBS instances, got %d", len(snapshot.PBSInstances))
}
// Update existing instance
state.UpdatePBSInstance(PBSInstance{ID: "pbs-1", Name: "Server 1 Updated"})
snapshot = state.GetSnapshot()
if len(snapshot.PBSInstances) != 2 {
t.Fatalf("Expected 2 PBS instances after update, got %d", len(snapshot.PBSInstances))
}
// Find updated instance
var found bool
for _, inst := range snapshot.PBSInstances {
if inst.ID == "pbs-1" && inst.Name == "Server 1 Updated" {
found = true
break
}
}
if !found {
t.Error("Expected pbs-1 to be updated with new name")
}
}
func TestUpdatePMGInstances(t *testing.T) {
state := NewState()
instances := []PMGInstance{
{ID: "pmg-1", Name: "Mail Gateway 1"},
{ID: "pmg-2", Name: "Mail Gateway 2"},
}
state.UpdatePMGInstances(instances)
snapshot := state.GetSnapshot()
if len(snapshot.PMGInstances) != 2 {
t.Fatalf("Expected 2 PMG instances, got %d", len(snapshot.PMGInstances))
}
// Replace with empty
state.UpdatePMGInstances([]PMGInstance{})
snapshot = state.GetSnapshot()
if len(snapshot.PMGInstances) != 0 {
t.Fatalf("Expected 0 PMG instances after clearing, got %d", len(snapshot.PMGInstances))
}
}
func TestUpdatePMGInstance(t *testing.T) {
state := NewState()
// Add first instance
state.UpdatePMGInstance(PMGInstance{ID: "pmg-1", Name: "Gateway 1"})
snapshot := state.GetSnapshot()
if len(snapshot.PMGInstances) != 1 {
t.Fatalf("Expected 1 PMG instance, got %d", len(snapshot.PMGInstances))
}
// Add second instance
state.UpdatePMGInstance(PMGInstance{ID: "pmg-2", Name: "Gateway 2"})
snapshot = state.GetSnapshot()
if len(snapshot.PMGInstances) != 2 {
t.Fatalf("Expected 2 PMG instances, got %d", len(snapshot.PMGInstances))
}
// Update existing instance
state.UpdatePMGInstance(PMGInstance{ID: "pmg-1", Name: "Gateway 1 Updated"})
snapshot = state.GetSnapshot()
if len(snapshot.PMGInstances) != 2 {
t.Fatalf("Expected 2 PMG instances after update, got %d", len(snapshot.PMGInstances))
}
// Verify update
var found bool
for _, inst := range snapshot.PMGInstances {
if inst.ID == "pmg-1" && inst.Name == "Gateway 1 Updated" {
found = true
break
}
}
if !found {
t.Error("Expected pmg-1 to be updated with new name")
}
}
func TestUpdateCephClustersForInstance(t *testing.T) {
state := NewState()
// Add clusters from first instance
clusters1 := []CephCluster{
{ID: "ceph-1", Instance: "pve-1", Name: "ceph-pool-1"},
{ID: "ceph-2", Instance: "pve-1", Name: "ceph-pool-2"},
}
state.UpdateCephClustersForInstance("pve-1", clusters1)
snapshot := state.GetSnapshot()
if len(snapshot.CephClusters) != 2 {
t.Fatalf("Expected 2 clusters, got %d", len(snapshot.CephClusters))
}
// Add clusters from second instance
clusters2 := []CephCluster{
{ID: "ceph-3", Instance: "pve-2", Name: "ceph-pool-1"},
}
state.UpdateCephClustersForInstance("pve-2", clusters2)
snapshot = state.GetSnapshot()
if len(snapshot.CephClusters) != 3 {
t.Fatalf("Expected 3 clusters, got %d", len(snapshot.CephClusters))
}
// Update first instance with empty (should remove its clusters)
state.UpdateCephClustersForInstance("pve-1", []CephCluster{})
snapshot = state.GetSnapshot()
if len(snapshot.CephClusters) != 1 {
t.Fatalf("Expected 1 cluster after clearing pve-1, got %d", len(snapshot.CephClusters))
}
if snapshot.CephClusters[0].Instance != "pve-2" {
t.Error("Remaining cluster should be from pve-2")
}
}
func TestUpdateBackupTasksForInstance(t *testing.T) {
state := NewState()
now := time.Now()
// Add tasks from first instance
tasks1 := []BackupTask{
{ID: "pve-1-task-1", Instance: "pve-1", StartTime: now},
{ID: "pve-1-task-2", Instance: "pve-1", StartTime: now.Add(-time.Hour)},
}
state.UpdateBackupTasksForInstance("pve-1", tasks1)
snapshot := state.GetSnapshot()
if len(snapshot.PVEBackups.BackupTasks) != 2 {
t.Fatalf("Expected 2 tasks, got %d", len(snapshot.PVEBackups.BackupTasks))
}
// Tasks should be sorted by start time descending
if snapshot.PVEBackups.BackupTasks[0].StartTime.Before(snapshot.PVEBackups.BackupTasks[1].StartTime) {
t.Error("Tasks should be sorted by start time descending")
}
// Add tasks from second instance
tasks2 := []BackupTask{
{ID: "pve-2-task-1", Instance: "pve-2", StartTime: now.Add(-30 * time.Minute)},
}
state.UpdateBackupTasksForInstance("pve-2", tasks2)
snapshot = state.GetSnapshot()
if len(snapshot.PVEBackups.BackupTasks) != 3 {
t.Fatalf("Expected 3 tasks, got %d", len(snapshot.PVEBackups.BackupTasks))
}
// Update first instance (should replace its tasks)
tasks1Updated := []BackupTask{
{ID: "pve-1-task-3", Instance: "pve-1", StartTime: now.Add(time.Hour)},
}
state.UpdateBackupTasksForInstance("pve-1", tasks1Updated)
snapshot = state.GetSnapshot()
if len(snapshot.PVEBackups.BackupTasks) != 2 {
t.Fatalf("Expected 2 tasks after update, got %d", len(snapshot.PVEBackups.BackupTasks))
}
}
func TestUpdateReplicationJobsForInstance(t *testing.T) {
state := NewState()
// Add jobs from first instance
jobs1 := []ReplicationJob{
{ID: "job-1", Instance: "pve-1", GuestID: 100, JobNumber: 0},
{ID: "job-2", Instance: "pve-1", GuestID: 101, JobNumber: 0},
}
state.UpdateReplicationJobsForInstance("pve-1", jobs1)
snapshot := state.GetSnapshot()
if len(snapshot.ReplicationJobs) != 2 {
t.Fatalf("Expected 2 jobs, got %d", len(snapshot.ReplicationJobs))
}
// Add jobs from second instance
jobs2 := []ReplicationJob{
{ID: "job-3", Instance: "pve-2", GuestID: 200, JobNumber: 0},
}
state.UpdateReplicationJobsForInstance("pve-2", jobs2)
snapshot = state.GetSnapshot()
if len(snapshot.ReplicationJobs) != 3 {
t.Fatalf("Expected 3 jobs, got %d", len(snapshot.ReplicationJobs))
}
// Update first instance with empty (should remove its jobs)
state.UpdateReplicationJobsForInstance("pve-1", []ReplicationJob{})
snapshot = state.GetSnapshot()
if len(snapshot.ReplicationJobs) != 1 {
t.Fatalf("Expected 1 job after clearing pve-1, got %d", len(snapshot.ReplicationJobs))
}
if snapshot.ReplicationJobs[0].Instance != "pve-2" {
t.Error("Remaining job should be from pve-2")
}
}
func TestUpdateGuestSnapshotsForInstance(t *testing.T) {
state := NewState()
now := time.Now()
// Add snapshots from first instance
snapshots1 := []GuestSnapshot{
{ID: "pve-1-snap-1", Instance: "pve-1", VMID: 100, Name: "snapshot1", Time: now},
{ID: "pve-1-snap-2", Instance: "pve-1", VMID: 100, Name: "snapshot2", Time: now.Add(-time.Hour)},
}
state.UpdateGuestSnapshotsForInstance("pve-1", snapshots1)
snapshot := state.GetSnapshot()
if len(snapshot.PVEBackups.GuestSnapshots) != 2 {
t.Fatalf("Expected 2 snapshots, got %d", len(snapshot.PVEBackups.GuestSnapshots))
}
// Add snapshots from second instance
snapshots2 := []GuestSnapshot{
{ID: "pve-2-snap-1", Instance: "pve-2", VMID: 200, Name: "snapshot1", Time: now.Add(-30 * time.Minute)},
}
state.UpdateGuestSnapshotsForInstance("pve-2", snapshots2)
snapshot = state.GetSnapshot()
if len(snapshot.PVEBackups.GuestSnapshots) != 3 {
t.Fatalf("Expected 3 snapshots, got %d", len(snapshot.PVEBackups.GuestSnapshots))
}
// Update first instance (should replace its snapshots)
snapshots1Updated := []GuestSnapshot{
{ID: "pve-1-snap-3", Instance: "pve-1", VMID: 100, Name: "new-snapshot", Time: now.Add(time.Hour)},
}
state.UpdateGuestSnapshotsForInstance("pve-1", snapshots1Updated)
snapshot = state.GetSnapshot()
if len(snapshot.PVEBackups.GuestSnapshots) != 2 {
t.Fatalf("Expected 2 snapshots after update, got %d", len(snapshot.PVEBackups.GuestSnapshots))
}
}
func TestSyncGuestBackupTimes(t *testing.T) {
state := NewState()
now := time.Now()
oldBackup := now.Add(-24 * time.Hour)
newBackup := now.Add(-1 * time.Hour)
// Set up VMs and containers
state.UpdateVMs([]VM{
{VMID: 100, Name: "vm-100", Instance: "pve-1"},
{VMID: 101, Name: "vm-101", Instance: "pve-1"},
})
state.UpdateContainers([]Container{
{VMID: 200, Name: "ct-200", Instance: "pve-1"},
})
// Add storage backups for VM 100 (must include Instance for proper matching)
state.mu.Lock()
state.PVEBackups.StorageBackups = []StorageBackup{
{ID: "pve-1-backup-1", VMID: 100, Instance: "pve-1", Time: oldBackup},
{ID: "pve-1-backup-2", VMID: 100, Instance: "pve-1", Time: newBackup}, // newer
}
state.mu.Unlock()
// Add PBS backup for container 200
state.UpdatePBSBackups("pbs-1", []PBSBackup{
{ID: "pbs-backup-1", VMID: "200", BackupTime: newBackup},
})
// Sync backup times
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
// VM 100 should have the newer backup time
var vm100 *VM
for i := range snapshot.VMs {
if snapshot.VMs[i].VMID == 100 {
vm100 = &snapshot.VMs[i]
break
}
}
if vm100 == nil {
t.Fatal("VM 100 not found")
}
if !vm100.LastBackup.Equal(newBackup) {
t.Errorf("VM 100 LastBackup = %v, expected %v", vm100.LastBackup, newBackup)
}
// VM 101 should have no backup time (zero)
var vm101 *VM
for i := range snapshot.VMs {
if snapshot.VMs[i].VMID == 101 {
vm101 = &snapshot.VMs[i]
break
}
}
if vm101 == nil {
t.Fatal("VM 101 not found")
}
if !vm101.LastBackup.IsZero() {
t.Errorf("VM 101 LastBackup should be zero, got %v", vm101.LastBackup)
}
// Container 200 should have backup time from PBS
var ct200 *Container
for i := range snapshot.Containers {
if snapshot.Containers[i].VMID == 200 {
ct200 = &snapshot.Containers[i]
break
}
}
if ct200 == nil {
t.Fatal("Container 200 not found")
}
if !ct200.LastBackup.Equal(newBackup) {
t.Errorf("Container 200 LastBackup = %v, expected %v", ct200.LastBackup, newBackup)
}
}
// TestSyncGuestBackupTimesCrossInstance verifies that backup matching uses instance+VMID.
// This prevents a newly created container on one instance from incorrectly showing
// backup time from a different container with the same VMID on another instance.
func TestSyncGuestBackupTimesCrossInstance(t *testing.T) {
state := NewState()
now := time.Now()
threeMonthsAgo := now.Add(-90 * 24 * time.Hour)
// Set up containers with the SAME VMID on DIFFERENT instances
// This simulates: pve-1 has VMID 100 with old backup, pve-2 has newly created VMID 100
state.UpdateContainers([]Container{
{VMID: 100, Name: "old-container", Instance: "pve-1"},
{VMID: 100, Name: "new-container", Instance: "pve-2"}, // newly created, no backup
})
// Add a 3-month-old backup for pve-1's container (VMID 100)
state.mu.Lock()
state.PVEBackups.StorageBackups = []StorageBackup{
{ID: "pve-1-backup-old", VMID: 100, Instance: "pve-1", Time: threeMonthsAgo},
}
state.mu.Unlock()
// Sync backup times
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
// Find both containers
var oldContainer, newContainer *Container
for i := range snapshot.Containers {
if snapshot.Containers[i].Instance == "pve-1" && snapshot.Containers[i].VMID == 100 {
oldContainer = &snapshot.Containers[i]
}
if snapshot.Containers[i].Instance == "pve-2" && snapshot.Containers[i].VMID == 100 {
newContainer = &snapshot.Containers[i]
}
}
if oldContainer == nil {
t.Fatal("pve-1 container not found")
}
if newContainer == nil {
t.Fatal("pve-2 container not found")
}
// The old container on pve-1 SHOULD have the backup time
if !oldContainer.LastBackup.Equal(threeMonthsAgo) {
t.Errorf("pve-1 container LastBackup = %v, expected %v", oldContainer.LastBackup, threeMonthsAgo)
}
// The NEW container on pve-2 should NOT have any backup time (it's a different container!)
if !newContainer.LastBackup.IsZero() {
t.Errorf("pve-2 container (newly created) should have no backup, got %v", newContainer.LastBackup)
}
}
// TestSyncGuestBackupTimesNamespaceDisambiguation verifies that PBS namespace is used to
// disambiguate backups when multiple VMs have the same VMID across different PVE instances.
// This addresses issue #1095 where users have multiple PVE instances with overlapping VMIDs.
func TestSyncGuestBackupTimesNamespaceDisambiguation(t *testing.T) {
state := NewState()
now := time.Now()
pveBackupTime := now.Add(-1 * time.Hour)
pveNatBackupTime := now.Add(-2 * time.Hour)
// Set up VMs with the SAME VMID on DIFFERENT instances
state.UpdateVMs([]VM{
{VMID: 100, Name: "webserver-pve", Instance: "pve", Node: "node1"},
{VMID: 100, Name: "webserver-nat", Instance: "pve-nat", Node: "node2"},
})
// Add PBS backups with namespaces that correspond to the PVE instances
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{
ID: "pbs-pve-100",
VMID: "100",
Namespace: "pve",
BackupType: "vm",
BackupTime: pveBackupTime,
Instance: "pbs-main",
},
{
ID: "pbs-nat-100",
VMID: "100",
Namespace: "nat", // Should match "pve-nat" instance
BackupType: "vm",
BackupTime: pveNatBackupTime,
Instance: "pbs-main",
},
}
state.mu.Unlock()
// Sync backup times
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
// Find both VMs
var vmPVE, vmNAT *VM
for i := range snapshot.VMs {
if snapshot.VMs[i].Instance == "pve" && snapshot.VMs[i].VMID == 100 {
vmPVE = &snapshot.VMs[i]
}
if snapshot.VMs[i].Instance == "pve-nat" && snapshot.VMs[i].VMID == 100 {
vmNAT = &snapshot.VMs[i]
}
}
if vmPVE == nil {
t.Fatal("pve VM not found")
}
if vmNAT == nil {
t.Fatal("pve-nat VM not found")
}
// The pve VM should have the backup with namespace "pve"
if !vmPVE.LastBackup.Equal(pveBackupTime) {
t.Errorf("pve VM LastBackup = %v, expected %v (from namespace 'pve')", vmPVE.LastBackup, pveBackupTime)
}
// The pve-nat VM should have the backup with namespace "nat"
if !vmNAT.LastBackup.Equal(pveNatBackupTime) {
t.Errorf("pve-nat VM LastBackup = %v, expected %v (from namespace 'nat')", vmNAT.LastBackup, pveNatBackupTime)
}
}
// 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.
func TestSyncGuestBackupTimesVMIDCollisionNonMatchingNamespace(t *testing.T) {
state := NewState()
now := time.Now()
backupTime := now.Add(-1 * time.Hour)
// Two VMs with the same VMID on different instances
state.UpdateVMs([]VM{
{VMID: 100, Name: "vm-pve1", Instance: "pve1", Node: "node1"},
{VMID: 100, Name: "vm-pve2", Instance: "pve2", Node: "node2"},
})
// PBS backup with a namespace that matches neither instance
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{
ID: "pbs-100",
VMID: "100",
Namespace: "staging",
BackupType: "vm",
BackupTime: backupTime,
Instance: "pbs-main",
},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
if vm.VMID == 100 && !vm.LastBackup.IsZero() {
t.Errorf("VM %q on %s should have zero LastBackup (ambiguous VMID, non-matching namespace), got %v",
vm.Name, vm.Instance, vm.LastBackup)
}
}
}
// TestSyncGuestBackupTimesVMIDCollisionEmptyNamespace verifies that when the same VMID
// exists on multiple PVE instances and a PBS backup has no namespace, both guests
// get zero LastBackup.
func TestSyncGuestBackupTimesVMIDCollisionEmptyNamespace(t *testing.T) {
state := NewState()
now := time.Now()
backupTime := now.Add(-1 * time.Hour)
state.UpdateVMs([]VM{
{VMID: 100, Name: "vm-pve1", Instance: "pve1", Node: "node1"},
{VMID: 100, Name: "vm-pve2", Instance: "pve2", Node: "node2"},
})
// PBS backup with empty namespace
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{
ID: "pbs-100",
VMID: "100",
Namespace: "",
BackupType: "vm",
BackupTime: backupTime,
Instance: "pbs-main",
},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
if vm.VMID == 100 && !vm.LastBackup.IsZero() {
t.Errorf("VM %q on %s should have zero LastBackup (ambiguous VMID, empty namespace), got %v",
vm.Name, vm.Instance, vm.LastBackup)
}
}
}
// TestSyncGuestBackupTimesUniqueVMIDFallback verifies that a unique VMID still gets
// the PBS backup via fallback even when the namespace doesn't match.
func TestSyncGuestBackupTimesUniqueVMIDFallback(t *testing.T) {
state := NewState()
now := time.Now()
backupTime := now.Add(-1 * time.Hour)
// Single VM — VMID is unique
state.UpdateVMs([]VM{
{VMID: 100, Name: "my-vm", Instance: "pve1", Node: "node1"},
})
// PBS backup with a namespace that does NOT match the instance
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{
ID: "pbs-100",
VMID: "100",
Namespace: "daily",
BackupType: "vm",
BackupTime: backupTime,
Instance: "pbs-main",
},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
var found *VM
for i := range snapshot.VMs {
if snapshot.VMs[i].VMID == 100 {
found = &snapshot.VMs[i]
}
}
if found == nil {
t.Fatal("VM not found")
}
if !found.LastBackup.Equal(backupTime) {
t.Errorf("unique VMID should fall back to PBS backup; got LastBackup = %v, want %v",
found.LastBackup, backupTime)
}
}
// TestSyncGuestBackupTimesVMContainerCollision verifies that when a VM and Container
// share the same VMID on different instances, neither gets an unmatched PBS backup.
func TestSyncGuestBackupTimesVMContainerCollision(t *testing.T) {
state := NewState()
now := time.Now()
backupTime := now.Add(-1 * time.Hour)
state.UpdateVMs([]VM{
{VMID: 100, Name: "vm-pve1", Instance: "pve1", Node: "node1"},
})
state.UpdateContainers([]Container{
{VMID: 100, Name: "ct-pve2", Instance: "pve2", Node: "node2"},
})
// PBS backup with namespace matching neither
state.mu.Lock()
state.PBSBackups = []PBSBackup{
{
ID: "pbs-100",
VMID: "100",
Namespace: "other",
BackupType: "vm",
BackupTime: backupTime,
Instance: "pbs-main",
},
}
state.mu.Unlock()
state.SyncGuestBackupTimes()
snapshot := state.GetSnapshot()
for _, vm := range snapshot.VMs {
if vm.VMID == 100 && !vm.LastBackup.IsZero() {
t.Errorf("VM %q should have zero LastBackup (ambiguous VMID with container), got %v",
vm.Name, vm.LastBackup)
}
}
for _, ct := range snapshot.Containers {
if ct.VMID == 100 && !ct.LastBackup.IsZero() {
t.Errorf("Container %q should have zero LastBackup (ambiguous VMID with VM), got %v",
ct.Name, ct.LastBackup)
}
}
}
func TestUpdateStorageBackupsForInstance(t *testing.T) {
state := NewState()
now := time.Now()
// Set up a VM so node normalization has something to work with
state.UpdateVMsForInstance("pve-1", []VM{
{VMID: 100, Instance: "pve-1", Node: "node1"},
})
// Add backups from first instance
backups1 := []StorageBackup{
{ID: "pve-1-backup-1", Instance: "pve-1", VMID: 100, Time: now, Node: "node1"},
{ID: "pve-1-backup-2", Instance: "pve-1", VMID: 100, Time: now.Add(-time.Hour), Node: "node1"},
}
state.UpdateStorageBackupsForInstance("pve-1", backups1)
snapshot := state.GetSnapshot()
if len(snapshot.PVEBackups.StorageBackups) != 2 {
t.Fatalf("Expected 2 backups, got %d", len(snapshot.PVEBackups.StorageBackups))
}
// Backups should be sorted by time descending
if snapshot.PVEBackups.StorageBackups[0].Time.Before(snapshot.PVEBackups.StorageBackups[1].Time) {
t.Error("Backups should be sorted by time descending")
}
// Add backups from second instance
backups2 := []StorageBackup{
{ID: "pve-2-backup-1", Instance: "pve-2", VMID: 200, Time: now.Add(-30 * time.Minute), Node: "node2"},
}
state.UpdateStorageBackupsForInstance("pve-2", backups2)
snapshot = state.GetSnapshot()
if len(snapshot.PVEBackups.StorageBackups) != 3 {
t.Fatalf("Expected 3 backups, got %d", len(snapshot.PVEBackups.StorageBackups))
}
// Update first instance (should replace its backups)
backups1Updated := []StorageBackup{
{ID: "pve-1-backup-3", Instance: "pve-1", VMID: 100, Time: now.Add(time.Hour), Node: "node1"},
}
state.UpdateStorageBackupsForInstance("pve-1", backups1Updated)
snapshot = state.GetSnapshot()
if len(snapshot.PVEBackups.StorageBackups) != 2 {
t.Fatalf("Expected 2 backups after update, got %d", len(snapshot.PVEBackups.StorageBackups))
}
// Verify old pve-1 backups were replaced
for _, b := range snapshot.PVEBackups.StorageBackups {
if b.ID == "pve-1-backup-1" || b.ID == "pve-1-backup-2" {
t.Error("Old pve-1 backups should have been replaced")
}
}
}