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 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 TestPVETagStyleSnapshotRebuildsAggregateByInstance(t *testing.T) { state := NewState() state.MergePVETagStyle("pve-a", PVETagStyle{ Colors: map[string]string{ "Production": "#00ff00", }, CaseSensitive: true, }) state.MergePVETagStyle("pve-b", PVETagStyle{ Colors: map[string]string{ "Backup": "#112233", }, }) snapshot := state.GetSnapshot() if got := snapshot.PVETagStyles["pve-a"].Colors["Production"]; got != "#00ff00" { t.Fatalf("expected pve-a tag style to preserve case-sensitive color, got %q", got) } if !snapshot.PVETagStyles["pve-a"].CaseSensitive { t.Fatal("expected pve-a tag style to preserve case-sensitive flag") } if got := snapshot.PVETagStyles["pve-b"].Colors["backup"]; got != "#112233" { t.Fatalf("expected pve-b tag style to normalize non-case-sensitive tag, got %q", got) } state.MergePVETagStyle("pve-a", PVETagStyle{}) snapshot = state.GetSnapshot() if _, ok := snapshot.PVETagColors["Production"]; ok { t.Fatalf("expected stale pve-a aggregate color to be removed, got %#v", snapshot.PVETagColors) } if got := snapshot.PVETagColors["backup"]; got != "#112233" { t.Fatalf("expected pve-b aggregate color to remain, got %q", got) } } 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 TestPBSInstanceJobHealthEvidenceNormalizeAndClone(t *testing.T) { state := NewState() observedAt := time.Unix(1700000000, 0).UTC() state.UpdatePBSInstance(PBSInstance{ ID: "pbs-1", Name: "pbs-main", JobHealthEvidence: []PBSJobHealthEvidence{{ ID: "sync-1", Family: "sync", Store: "fast", LastRunState: "OK", LastRunUPID: "UPID:sync:1", LastRunEndtime: 1699999900, NextRun: 1700003600, Confidence: "direct-task-match", EvidenceSource: "pbs-job-config", EvidenceScope: "configured-job", Freshness: PBSJobHealthFreshness{ ObservedAt: observedAt, State: "observed", }, Posture: "healthy", }}, }) snapshot := state.GetSnapshot() if len(snapshot.PBSInstances) != 1 || len(snapshot.PBSInstances[0].JobHealthEvidence) != 1 { t.Fatalf("expected cloned PBS job health evidence, got %+v", snapshot.PBSInstances) } snapshot.PBSInstances[0].JobHealthEvidence[0].LastRunState = "changed" fresh := state.GetSnapshot() if got := fresh.PBSInstances[0].JobHealthEvidence[0].LastRunState; got != "OK" { t.Fatalf("state clone leaked mutation, got last-run-state %q", got) } if got := fresh.PBSInstances[0].JobHealthEvidence[0]; got.EvidenceSource != "pbs-job-config" || got.EvidenceScope != "configured-job" { t.Fatalf("state clone did not preserve evidence source/scope: %+v", got) } normalized := (PBSInstance{}).NormalizeCollections() if normalized.JobHealthEvidence == nil { t.Fatal("JobHealthEvidence should normalize to an initialized empty slice") } } 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 TestCephClusterStatePrefersProxmoxAPIByFSID(t *testing.T) { state := NewState() state.UpsertCephCluster(CephCluster{ ID: "fsid-123", Instance: "pve5", Source: CephClusterSourceHostAgent, Name: "pve5 Ceph", FSID: "fsid-123", Pools: []CephPool{{ ID: 2, Name: "data_replication", StoredBytes: 70, AvailableBytes: 30, PercentUsed: 70, }}, }) stored := state.UpdateCephClustersForInstance("prod", []CephCluster{{ ID: "prod-fsid-123", Instance: "prod", Source: CephClusterSourceProxmoxAPI, Name: "Ceph", FSID: "fsid-123", Health: "HEALTH_OK", }}) snapshot := state.GetSnapshot() if len(snapshot.CephClusters) != 1 { t.Fatalf("expected API and agent reports for same FSID to reconcile to one cluster, got %#v", snapshot.CephClusters) } cluster := snapshot.CephClusters[0] if cluster.Source != CephClusterSourceProxmoxAPI || cluster.Instance != "prod" || cluster.ID != "prod-fsid-123" { t.Fatalf("expected Proxmox API cluster to be canonical, got %+v", cluster) } if !containsString(cluster.InstanceAliases, "pve5") { t.Fatalf("expected agent instance alias to be preserved, got %#v", cluster.InstanceAliases) } if len(cluster.Pools) != 1 || cluster.Pools[0].Name != "data_replication" { t.Fatalf("expected agent pool data to supplement API status-only cluster, got %#v", cluster.Pools) } if len(stored) != 1 || stored[0].Instance != "prod" || !containsString(stored[0].InstanceAliases, "pve5") { t.Fatalf("stored clusters did not return reconciled canonical cluster: %#v", stored) } } func TestUpsertCephClusterKeepsProxmoxAPIWhenAgentRefreshArrives(t *testing.T) { state := NewState() state.UpdateCephClustersForInstance("prod", []CephCluster{{ ID: "prod-fsid-123", Instance: "prod", Source: CephClusterSourceProxmoxAPI, Name: "Ceph", FSID: "fsid-123", Health: "HEALTH_OK", Pools: []CephPool{{ ID: 2, Name: "data_replication", StoredBytes: 60, AvailableBytes: 40, PercentUsed: 60, }}, }}) stored := state.UpsertCephCluster(CephCluster{ ID: "fsid-123", Instance: "pve5", Source: CephClusterSourceHostAgent, Name: "pve5 Ceph", FSID: "fsid-123", }) snapshot := state.GetSnapshot() if len(snapshot.CephClusters) != 1 { t.Fatalf("expected agent refresh to merge with API cluster, got %#v", snapshot.CephClusters) } cluster := snapshot.CephClusters[0] if cluster.Source != CephClusterSourceProxmoxAPI || cluster.Instance != "prod" { t.Fatalf("expected API cluster to remain canonical, got %+v", cluster) } if !containsString(cluster.InstanceAliases, "pve5") { t.Fatalf("expected agent instance alias to be preserved, got %#v", cluster.InstanceAliases) } if stored.Source != CephClusterSourceProxmoxAPI || stored.Instance != "prod" { t.Fatalf("expected upsert to return canonical API cluster, got %+v", stored) } } 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", BackupType: "ct", 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) } } // TestSyncGuestBackupTimesClusterEntrypointUsesGuestNodeNamespace verifies that // a cluster API entrypoint name does not shadow the guest's actual node namespace. func TestSyncGuestBackupTimesClusterEntrypointUsesGuestNodeNamespace(t *testing.T) { state := NewState() now := time.Now() oldBackupTime := now.Add(-180 * 24 * time.Hour) freshBackupTime := now.Add(-1 * time.Hour) state.UpdateContainers([]Container{ {VMID: 112, Name: "debian-go", Instance: "delly", Node: "minipc"}, }) state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-delly-112-old", VMID: "112", Namespace: "delly", BackupType: "ct", Comment: "112", BackupTime: oldBackupTime, Instance: "pbs-main", }, { ID: "pbs-minipc-112-fresh", VMID: "112", Namespace: "minipc", BackupType: "ct", Comment: "debian-go", BackupTime: freshBackupTime, Instance: "pbs-main", }, } state.mu.Unlock() state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() var found *Container for i := range snapshot.Containers { if snapshot.Containers[i].VMID == 112 { found = &snapshot.Containers[i] break } } if found == nil { t.Fatal("container 112 not found") } if !found.LastBackup.Equal(freshBackupTime) { t.Errorf("cluster entrypoint guest LastBackup = %v, want fresh node-namespaced backup %v", found.LastBackup, freshBackupTime) } } func TestSyncGuestBackupTimesClearsStaleBackupWhenCurrentEvidenceDisappears(t *testing.T) { state := NewState() staleBackupTime := time.Now().Add(-30 * 24 * time.Hour) state.UpdateVMs([]VM{ {VMID: 101, Name: "unbacked-vm", Instance: "pve-1", Node: "node1", LastBackup: staleBackupTime}, }) state.UpdateContainers([]Container{ {VMID: 201, Name: "unbacked-ct", Instance: "pve-1", Node: "node1", LastBackup: staleBackupTime}, }) state.SyncGuestBackupTimes() snapshot := state.GetSnapshot() for _, vm := range snapshot.VMs { if vm.VMID == 101 && !vm.LastBackup.IsZero() { t.Errorf("VM stale LastBackup should be cleared, got %v", vm.LastBackup) } } for _, ct := range snapshot.Containers { if ct.VMID == 201 && !ct.LastBackup.IsZero() { t.Errorf("container stale LastBackup should be cleared, got %v", ct.LastBackup) } } } // 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 PBS vm/ID and ct/ID // subjects are matched independently even when a VM and container share the same // numeric ID. 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"}, }) state.mu.Lock() state.PBSBackups = []PBSBackup{ { ID: "pbs-100", VMID: "100", Namespace: "node1", 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.Equal(backupTime) { t.Errorf("VM %q LastBackup = %v, want typed vm/100 backup %v", vm.Name, vm.LastBackup, backupTime) } } for _, ct := range snapshot.Containers { if ct.VMID == 100 && !ct.LastBackup.IsZero() { t.Errorf("Container %q should have zero LastBackup because the PBS subject is vm/100, 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") } } }