package alerts import ( "fmt" "math" "reflect" "strings" "testing" "time" "github.com/rcourtman/pulse-go-rewrite/internal/models" "github.com/rcourtman/pulse-go-rewrite/pkg/proxmox" ) func TestAcknowledgePersistsThroughCheckMetric(t *testing.T) { m := NewManager() m.ClearActiveAlerts() // Set config fields directly to bypass UpdateConfig's default value enforcement m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.SuppressionWindow = 0 m.config.MinimumDelta = 0 m.mu.Unlock() threshold := &HysteresisThreshold{Trigger: 80, Clear: 70} m.checkMetric("res1", "Resource", "node1", "inst1", "guest", "usage", 90, threshold, nil) if _, exists := m.activeAlerts["res1-usage"]; !exists { t.Fatalf("expected alert to be created") } if err := m.AcknowledgeAlert("res1-usage", "tester"); err != nil { t.Fatalf("ack failed: %v", err) } if !m.activeAlerts["res1-usage"].Acknowledged { t.Fatalf("acknowledged flag not set") } alerts := m.GetActiveAlerts() if len(alerts) != 1 || !alerts[0].Acknowledged { t.Fatalf("GetActiveAlerts lost acknowledgement") } m.checkMetric("res1", "Resource", "node1", "inst1", "guest", "usage", 85, threshold, nil) if !m.activeAlerts["res1-usage"].Acknowledged { t.Fatalf("acknowledged flag lost after update") } } func TestCheckGuestSkipsAlertsWhenMetricDisabled(t *testing.T) { m := NewManager() vmID := "instance-node-101" instanceName := "instance" // Start with default configuration to allow CPU alerts. initialConfig := AlertConfig{ Enabled: true, GuestDefaults: ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 80, Clear: 75}, }, TimeThreshold: 0, TimeThresholds: map[string]int{}, NodeDefaults: ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 80, Clear: 75}, Memory: &HysteresisThreshold{Trigger: 85, Clear: 80}, Disk: &HysteresisThreshold{Trigger: 90, Clear: 85}, }, StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80}, Overrides: make(map[string]ThresholdConfig), } m.UpdateConfig(initialConfig) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.ActivationState = ActivationActive m.mu.Unlock() var dispatched []*Alert done := make(chan struct{}, 1) var resolved []string resolvedDone := make(chan struct{}, 1) m.SetAlertCallback(func(alert *Alert) { dispatched = append(dispatched, alert) select { case done <- struct{}{}: default: } }) m.SetResolvedCallback(func(alertID string) { resolved = append(resolved, alertID) select { case resolvedDone <- struct{}{}: default: } }) vm := models.VM{ ID: vmID, Name: "test-vm", Node: "node", Instance: instanceName, Status: "running", CPU: 1.0, // 100% once multiplied by 100 inside CheckGuest Memory: models.Memory{ Usage: 65, }, Disk: models.Disk{ Usage: 40, }, } // Initial check should trigger an alert with default thresholds. m.CheckGuest(vm, instanceName) select { case <-done: case <-time.After(100 * time.Millisecond): t.Fatalf("did not receive initial alert dispatch") } if len(dispatched) != 1 { t.Fatalf("expected 1 alert before disabling metric, got %d", len(dispatched)) } // Apply override disabling CPU alerts for this VM. disabledConfig := initialConfig disabledConfig.Overrides = map[string]ThresholdConfig{ vmID: { CPU: &HysteresisThreshold{Trigger: -1, Clear: 0}, }, } disabledConfig.TimeThreshold = 0 disabledConfig.TimeThresholds = map[string]int{} m.UpdateConfig(disabledConfig) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.ActivationState = ActivationActive m.mu.Unlock() // Clear dispatched slice to capture only post-disable notifications. dispatched = dispatched[:0] done = make(chan struct{}, 1) // Re-run evaluation with high CPU; no alert should be dispatched. m.CheckGuest(vm, instanceName) select { case <-done: t.Fatalf("expected no alerts after disabling CPU metric, but callback fired") case <-time.After(100 * time.Millisecond): // No callback fired as expected. } // Active alerts should be cleared by the config update. m.mu.RLock() activeCount := len(m.activeAlerts) m.mu.RUnlock() if activeCount != 0 { t.Fatalf("expected active alerts to be cleared after disabling metric, got %d", activeCount) } select { case <-resolvedDone: case <-time.After(100 * time.Millisecond): t.Fatalf("expected resolved callback to fire after disabling metric") } if len(resolved) != 1 || resolved[0] != fmt.Sprintf("%s-cpu", vmID) { t.Fatalf("expected resolved callback for %s-cpu, got %v", vmID, resolved) } m.mu.RLock() _, isPending := m.pendingAlerts[fmt.Sprintf("%s-cpu", vmID)] m.mu.RUnlock() if isPending { t.Fatalf("expected pending alert entry to be cleared after disabling metric") } } func TestPulseNoAlertsSuppressesGuestAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.ActivationState = ActivationActive m.mu.Unlock() var dispatched int m.SetAlertCallback(func(alert *Alert) { dispatched++ }) vm := models.VM{ ID: "inst/qemu/101", Name: "test-vm", Node: "node1", Instance: "inst", Status: "running", CPU: 1.0, Memory: models.Memory{ Usage: 95, }, Disk: models.Disk{ Usage: 95, }, Tags: []string{"pulse-no-alerts"}, } m.CheckGuest(vm, "inst") if dispatched != 0 { t.Fatalf("expected no alert dispatch, got %d", dispatched) } if alerts := m.GetActiveAlerts(); len(alerts) != 0 { t.Fatalf("expected no active alerts, got %d", len(alerts)) } } func TestPulseMonitorOnlySkipsDispatchButRetainsAlert(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.ActivationState = ActivationActive m.mu.Unlock() var dispatched int m.SetAlertCallback(func(alert *Alert) { dispatched++ }) vm := models.VM{ ID: "inst/qemu/102", Name: "monitor-vm", Node: "node1", Instance: "inst", Status: "running", CPU: 1.0, Memory: models.Memory{Usage: 90}, Disk: models.Disk{Usage: 50}, Tags: []string{"pulse-monitor-only"}, } m.CheckGuest(vm, "inst") if dispatched != 0 { t.Fatalf("expected monitor-only alert to skip dispatch, got %d callbacks", dispatched) } alerts := m.GetActiveAlerts() if len(alerts) == 0 { t.Fatalf("expected monitor-only alert to remain active") } if alerts[0].Metadata == nil || alerts[0].Metadata["monitorOnly"] != true { t.Fatalf("expected alert metadata to mark monitorOnly, got %+v", alerts[0].Metadata) } } func TestPulseRelaxedThresholdsIncreaseCpuTrigger(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() vm := models.VM{ ID: "inst/qemu/103", Name: "relaxed-vm", Node: "node1", Instance: "inst", Status: "running", CPU: 0.9, // 90% Memory: models.Memory{Usage: 60}, Disk: models.Disk{Usage: 40}, Tags: []string{"pulse-relaxed"}, } m.CheckGuest(vm, "inst") if alerts := m.GetActiveAlerts(); len(alerts) != 0 { t.Fatalf("expected no alerts at 90%% CPU with relaxed thresholds, got %d", len(alerts)) } vm.CPU = 1.0 m.CheckGuest(vm, "inst") if alerts := m.GetActiveAlerts(); len(alerts) == 0 { t.Fatalf("expected alert once CPU exceeds relaxed threshold") } } func TestClearAlertMarksResolutionAndReturnsStatus(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() vm := models.VM{ ID: "inst/qemu/104", Name: "clear-vm", Node: "node1", Instance: "inst", Status: "running", CPU: 1.0, Memory: models.Memory{Usage: 80}, Disk: models.Disk{Usage: 80}, } m.CheckGuest(vm, "inst") alerts := m.GetActiveAlerts() if len(alerts) == 0 { t.Fatalf("expected alert to be active before clearing") } alertID := alerts[0].ID if ok := m.ClearAlert(alertID); !ok { t.Fatalf("expected manual clear to succeed") } if remaining := m.GetActiveAlerts(); len(remaining) != 0 { t.Fatalf("expected no active alerts after clear, found %d", len(remaining)) } resolved := m.GetRecentlyResolved() if len(resolved) == 0 || resolved[0].Alert.ID != alertID { t.Fatalf("expected alert %s to be tracked as recently resolved", alertID) } if ok := m.ClearAlert(alertID); ok { t.Fatalf("expected second clear to report missing alert") } } func TestHandleDockerHostRemovedClearsAlertsAndTracking(t *testing.T) { m := NewManager() host := models.DockerHost{ID: "host1", DisplayName: "Host One", Hostname: "host-one"} containerResourceID := "docker:host1/container1" containerAlertID := "docker-container-state-" + containerResourceID hostAlertID := "docker-host-offline-host1" m.mu.Lock() m.activeAlerts[hostAlertID] = &Alert{ID: hostAlertID, ResourceID: "docker:host1"} m.activeAlerts[containerAlertID] = &Alert{ID: containerAlertID, ResourceID: containerResourceID} m.dockerOfflineCount[host.ID] = 2 m.dockerStateConfirm[containerResourceID] = 1 m.dockerRestartTracking[containerResourceID] = &dockerRestartRecord{} m.dockerLastExitCode[containerResourceID] = 137 m.mu.Unlock() m.HandleDockerHostRemoved(host) m.mu.RLock() defer m.mu.RUnlock() if _, exists := m.activeAlerts[containerAlertID]; exists { t.Fatalf("expected container alerts to be cleared") } if _, exists := m.activeAlerts[hostAlertID]; exists { t.Fatalf("expected host offline alert to be cleared") } if _, exists := m.dockerOfflineCount[host.ID]; exists { t.Fatalf("expected offline tracking to be cleared") } if _, exists := m.dockerStateConfirm[containerResourceID]; exists { t.Fatalf("expected state confirmation to be cleared") } if _, exists := m.dockerRestartTracking[containerResourceID]; exists { t.Fatalf("expected restart tracking to be cleared") } if _, exists := m.dockerLastExitCode[containerResourceID]; exists { t.Fatalf("expected last exit code tracking to be cleared") } } func TestCheckHostGeneratesMetricAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() host := models.Host{ ID: "host-1", DisplayName: "Test Host", Hostname: "host-1.example", Platform: "linux", OSName: "ubuntu", CPUUsage: 95, CPUCount: 8, Memory: models.Memory{ Usage: 92, Total: 16384, Used: 15000, Free: 1384, }, Disks: []models.Disk{ { Mountpoint: "/", Usage: 93, Total: 100, Used: 93, Free: 7, }, }, Status: "online", IntervalSeconds: 30, LastSeen: time.Now(), Tags: []string{"prod"}, } m.CheckHost(host) m.mu.RLock() defer m.mu.RUnlock() cpuAlertID := fmt.Sprintf("%s-cpu", hostResourceID(host.ID)) if _, exists := m.activeAlerts[cpuAlertID]; !exists { t.Fatalf("expected CPU alert %q to be active", cpuAlertID) } memAlertID := fmt.Sprintf("%s-memory", hostResourceID(host.ID)) if _, exists := m.activeAlerts[memAlertID]; !exists { t.Fatalf("expected memory alert %q to be active", memAlertID) } diskResourceID, _ := hostDiskResourceID(host, host.Disks[0]) diskAlertID := fmt.Sprintf("%s-disk", diskResourceID) if _, exists := m.activeAlerts[diskAlertID]; !exists { t.Fatalf("expected disk alert %q to be active", diskAlertID) } } func TestHandleHostOfflineRequiresConfirmations(t *testing.T) { m := NewManager() m.ClearActiveAlerts() host := models.Host{ID: "host-2", DisplayName: "Second Host", Hostname: "host-two"} alertID := fmt.Sprintf("host-offline-%s", host.ID) resourceKey := hostResourceID(host.ID) m.HandleHostOffline(host) m.mu.RLock() if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected no alert after first offline detection") } if count := m.offlineConfirmations[resourceKey]; count != 1 { t.Fatalf("expected confirmation count to be 1, got %d", count) } m.mu.RUnlock() m.HandleHostOffline(host) m.mu.RLock() if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected no alert after second offline detection") } if count := m.offlineConfirmations[resourceKey]; count != 2 { t.Fatalf("expected confirmation count to be 2, got %d", count) } m.mu.RUnlock() m.HandleHostOffline(host) m.mu.RLock() if _, exists := m.activeAlerts[alertID]; !exists { t.Fatalf("expected alert %q after third offline detection", alertID) } m.mu.RUnlock() m.HandleHostOnline(host) m.mu.RLock() if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected offline alert %q to be cleared after host online", alertID) } if _, exists := m.offlineConfirmations[resourceKey]; exists { t.Fatalf("expected offline confirmations to be cleared when host online") } m.mu.RUnlock() } func TestCheckHostDisabledOverrideClearsAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() host := models.Host{ ID: "host-3", DisplayName: "Override Host", Hostname: "override.example", CPUUsage: 90, Memory: models.Memory{ Usage: 91, Total: 16000, Used: 14560, Free: 1440, }, Disks: []models.Disk{ {Mountpoint: "/data", Usage: 92, Total: 200, Used: 184, Free: 16}, }, Status: "online", IntervalSeconds: 30, LastSeen: time.Now(), } m.CheckHost(host) m.mu.RLock() if len(m.activeAlerts) == 0 { m.mu.RUnlock() t.Fatalf("expected active alerts prior to disabling host overrides") } m.mu.RUnlock() cfg := m.GetConfig() cfg.Overrides = map[string]ThresholdConfig{ host.ID: { Disabled: true, }, } m.UpdateConfig(cfg) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() m.CheckHost(host) m.mu.RLock() defer m.mu.RUnlock() if len(m.activeAlerts) != 0 { t.Fatalf("expected all host alerts to be cleared after disabling override, got %d", len(m.activeAlerts)) } } func TestCheckSnapshotsForInstanceCreatesAndClearsAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() cfg := AlertConfig{ Enabled: true, StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80}, SnapshotDefaults: SnapshotAlertConfig{ Enabled: true, WarningDays: 7, CriticalDays: 14, WarningSizeGiB: 0, CriticalSizeGiB: 0, }, Overrides: make(map[string]ThresholdConfig), } m.UpdateConfig(cfg) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() now := time.Now() snapshots := []models.GuestSnapshot{ { ID: "inst-node-100-weekly", Name: "weekly", Node: "node", Instance: "inst", Type: "qemu", VMID: 100, Time: now.Add(-15 * 24 * time.Hour), SizeBytes: 60 << 30, }, } guestNames := map[string]string{ "inst-node-100": "app-server", } m.CheckSnapshotsForInstance("inst", snapshots, guestNames) m.mu.RLock() alert, exists := m.activeAlerts["snapshot-age-inst-node-100-weekly"] m.mu.RUnlock() if !exists { t.Fatalf("expected snapshot age alert to be created") } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical level for old snapshot, got %s", alert.Level) } if alert.ResourceName != "app-server snapshot 'weekly'" { t.Fatalf("unexpected resource name: %s", alert.ResourceName) } m.CheckSnapshotsForInstance("inst", nil, guestNames) m.mu.RLock() _, exists = m.activeAlerts["snapshot-age-inst-node-100-weekly"] m.mu.RUnlock() if exists { t.Fatalf("expected snapshot alert to be cleared when snapshot missing") } } func TestCheckSnapshotsForInstanceTriggersOnSnapshotSize(t *testing.T) { m := NewManager() m.ClearActiveAlerts() cfg := AlertConfig{ Enabled: true, StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80}, SnapshotDefaults: SnapshotAlertConfig{ Enabled: true, WarningDays: 0, CriticalDays: 0, WarningSizeGiB: 50, CriticalSizeGiB: 100, }, Overrides: make(map[string]ThresholdConfig), } m.UpdateConfig(cfg) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() now := time.Now() snapshots := []models.GuestSnapshot{ { ID: "inst-node-200-sizey", Name: "pre-maintenance", Node: "node", Instance: "inst", Type: "qemu", VMID: 200, Time: now.Add(-2 * time.Hour), SizeBytes: int64(120) << 30, }, } guestNames := map[string]string{ "inst-node-200": "db-server", } m.CheckSnapshotsForInstance("inst", snapshots, guestNames) m.mu.RLock() alert, exists := m.activeAlerts["snapshot-age-inst-node-200-sizey"] m.mu.RUnlock() if !exists { t.Fatalf("expected snapshot size alert to be created") } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical level for large snapshot, got %s", alert.Level) } if alert.Value < 119.5 || alert.Value > 120.5 { t.Fatalf("expected alert value near 120 GiB, got %.2f", alert.Value) } if alert.Threshold != 100 { t.Fatalf("expected threshold 100 GiB, got %.2f", alert.Threshold) } if alert.Metadata == nil { t.Fatalf("expected metadata for snapshot alert") } if metric, ok := alert.Metadata["primaryMetric"].(string); !ok || metric != "size" { t.Fatalf("expected primary metric size, got %#v", alert.Metadata["primaryMetric"]) } if sizeBytes, ok := alert.Metadata["snapshotSizeBytes"].(int64); !ok || sizeBytes == 0 { t.Fatalf("expected snapshotSizeBytes in metadata") } metrics, ok := alert.Metadata["triggeredMetrics"].([]string) if !ok { t.Fatalf("expected triggeredMetrics slice, got %#v", alert.Metadata["triggeredMetrics"]) } foundSize := false for _, metric := range metrics { if metric == "size" { foundSize = true break } } if !foundSize { t.Fatalf("expected size metric recorded in metadata") } } func TestCheckSnapshotsForInstanceIncludesAgeAndSizeReasons(t *testing.T) { m := NewManager() m.ClearActiveAlerts() cfg := AlertConfig{ Enabled: true, StorageDefault: HysteresisThreshold{Trigger: 85, Clear: 80}, SnapshotDefaults: SnapshotAlertConfig{ Enabled: true, WarningDays: 5, CriticalDays: 10, WarningSizeGiB: 40, CriticalSizeGiB: 80, }, Overrides: make(map[string]ThresholdConfig), } m.UpdateConfig(cfg) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() now := time.Now() snapshots := []models.GuestSnapshot{ { ID: "inst-node-300-combined", Name: "long-running", Node: "node", Instance: "inst", Type: "qemu", VMID: 300, Time: now.Add(-15 * 24 * time.Hour), SizeBytes: int64(90) << 30, }, } guestNames := map[string]string{ "inst-node-300": "app-server", } m.CheckSnapshotsForInstance("inst", snapshots, guestNames) m.mu.RLock() alert, exists := m.activeAlerts["snapshot-age-inst-node-300-combined"] m.mu.RUnlock() if !exists { t.Fatalf("expected combined snapshot alert to be created") } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical level, got %s", alert.Level) } if !strings.Contains(alert.Message, "days old") || !strings.Contains(strings.ToLower(alert.Message), "gib") { t.Fatalf("expected alert message to reference age and size, got %q", alert.Message) } if alert.Metadata == nil { t.Fatalf("expected metadata for combined alert") } metrics, ok := alert.Metadata["triggeredMetrics"].([]string) if !ok { t.Fatalf("expected triggeredMetrics slice, got %#v", alert.Metadata["triggeredMetrics"]) } if len(metrics) < 2 { t.Fatalf("expected both age and size metrics recorded, got %v", metrics) } if metric, ok := alert.Metadata["primaryMetric"].(string); !ok || metric != "age" { t.Fatalf("expected primary metric age, got %#v", alert.Metadata["primaryMetric"]) } } func TestCheckBackupsCreatesAndClearsAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.Enabled = true m.config.BackupDefaults = BackupAlertConfig{ Enabled: true, WarningDays: 7, CriticalDays: 14, } m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.mu.Unlock() now := time.Now() storageBackups := []models.StorageBackup{ { ID: "inst-node-100-backup", Storage: "local", Node: "node", Instance: "inst", Type: "qemu", VMID: 100, Time: now.Add(-15 * 24 * time.Hour), }, } key := BuildGuestKey("inst", "node", 100) guestsByKey := map[string]GuestLookup{ key: { Name: "app-server", Instance: "inst", Node: "node", Type: "qemu", VMID: 100, }, } guestsByVMID := map[string][]GuestLookup{ "100": {guestsByKey[key]}, } m.CheckBackups(storageBackups, nil, nil, guestsByKey, guestsByVMID) m.mu.RLock() alert, exists := m.activeAlerts["backup-age-"+sanitizeAlertKey(key)] m.mu.RUnlock() if !exists { t.Fatalf("expected backup age alert to be created") } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical backup alert, got %s", alert.Level) } // Recent backup clears alert storageBackups[0].Time = now m.CheckBackups(storageBackups, nil, nil, guestsByKey, guestsByVMID) m.mu.RLock() _, exists = m.activeAlerts["backup-age-"+sanitizeAlertKey(key)] m.mu.RUnlock() if exists { t.Fatalf("expected backup-age alert to clear after fresh backup") } } func TestCheckBackupsHandlesPbsOnlyGuests(t *testing.T) { m := NewManager() 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-backup-999-0", Instance: "pbs-main", Datastore: "backup-store", BackupType: "qemu", VMID: "999", BackupTime: now.Add(-6 * 24 * time.Hour), }, } m.CheckBackups(nil, pbsBackups, nil, map[string]GuestLookup{}, map[string][]GuestLookup{}) m.mu.RLock() found := false for id, alert := range m.activeAlerts { if strings.HasPrefix(id, "backup-age-") { found = true if alert.Level != AlertLevelCritical { t.Fatalf("expected PBS backup alert to be critical") } break } } m.mu.RUnlock() if !found { t.Fatalf("expected PBS backup alert to be created") } } func TestCheckBackupsHandlesPmgBackups(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.Lock() m.config.Enabled = true m.config.BackupDefaults = BackupAlertConfig{ Enabled: true, WarningDays: 5, CriticalDays: 7, } m.mu.Unlock() now := time.Now() pmgBackups := []models.PMGBackup{ { ID: "pmg-backup-mail-01", Instance: "mail", Node: "mail-gateway", Filename: "pmg-backup_2024-01-01.tgz", BackupTime: now.Add(-8 * 24 * time.Hour), Size: 123456, }, } m.CheckBackups(nil, nil, pmgBackups, map[string]GuestLookup{}, map[string][]GuestLookup{}) m.mu.RLock() found := false for id, alert := range m.activeAlerts { if strings.HasPrefix(id, "backup-age-") { found = true if alert.Level != AlertLevelCritical { t.Fatalf("expected PMG backup alert to be critical") } break } } m.mu.RUnlock() if !found { t.Fatalf("expected PMG backup alert to be created") } } func TestCheckDockerHostIgnoresContainersByPrefix(t *testing.T) { m := NewManager() m.mu.Lock() m.config.DockerIgnoredContainerPrefixes = []string{"runner-"} m.mu.Unlock() container := models.DockerContainer{ ID: "1234567890ab", Name: "runner-auto-1", State: "exited", Status: "Exited (0) 3 seconds ago", } host := models.DockerHost{ ID: "host-ephemeral", Hostname: "ci-host", DisplayName: "CI Host", Containers: []models.DockerContainer{container}, } resourceID := dockerResourceID(host.ID, container.ID) alertID := fmt.Sprintf("docker-container-state-%s", resourceID) // Run twice to satisfy the confirmation threshold when not ignored m.CheckDockerHost(host) m.CheckDockerHost(host) if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected no state alert for ignored container") } if _, exists := m.dockerStateConfirm[resourceID]; exists { t.Fatalf("expected no state confirmation tracking for ignored container") } } func TestDockerServiceReplicaAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() m.mu.RLock() cfg := m.config m.mu.RUnlock() cfg.Enabled = true m.UpdateConfig(cfg) host := models.DockerHost{ ID: "host-1", DisplayName: "Prod Swarm", Hostname: "swarm-prod", Services: []models.DockerService{ { ID: "svc-1", Name: "web", DesiredTasks: 4, RunningTasks: 2, Mode: "replicated", }, }, } m.CheckDockerHost(host) resourceID := dockerServiceResourceID(host.ID, "svc-1", "web") alertID := fmt.Sprintf("docker-service-health-%s", resourceID) alert, exists := m.activeAlerts[alertID] if !exists { t.Fatalf("expected service alert %s to be raised", alertID) } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical severity, got %s", alert.Level) } if missing, ok := alert.Metadata["missingTasks"].(int); !ok || missing != 2 { t.Fatalf("expected missingTasks metadata to be 2, got %v", alert.Metadata["missingTasks"]) } // Resolve by restoring replicas host.Services[0].RunningTasks = 4 m.CheckDockerHost(host) if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected service alert %s to be cleared when replicas restored", alertID) } } func TestDockerServiceUpdateStateAlert(t *testing.T) { m := NewManager() cfg := m.GetConfig() cfg.Enabled = true m.UpdateConfig(cfg) now := time.Now() host := models.DockerHost{ ID: "host-update", DisplayName: "Swarm", Hostname: "swarm.local", Services: []models.DockerService{ { ID: "svc-update", Name: "api", DesiredTasks: 1, RunningTasks: 1, UpdateStatus: &models.DockerServiceUpdate{ State: "rollback_failed", Message: "Rollback failed", CompletedAt: &now, }, }, }, } m.CheckDockerHost(host) resourceID := dockerServiceResourceID(host.ID, "svc-update", "api") alertID := fmt.Sprintf("docker-service-health-%s", resourceID) alert, exists := m.activeAlerts[alertID] if !exists { t.Fatalf("expected docker service alert %s to be raised", alertID) } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical severity for rollback failure, got %s", alert.Level) } if state, ok := alert.Metadata["updateState"].(string); !ok || state != "rollback_failed" { t.Fatalf("expected updateState metadata to be rollback_failed, got %v", alert.Metadata["updateState"]) } } func TestDockerContainerStateUsesDockerDefaults(t *testing.T) { m := NewManager() cfg := m.GetConfig() cfg.DockerDefaults.StatePoweredOffSeverity = AlertLevelCritical m.UpdateConfig(cfg) container := models.DockerContainer{ ID: "container-1", Name: "web", State: "exited", Status: "Exited (1) seconds ago", } host := models.DockerHost{ ID: "host-1", DisplayName: "Docker Host", Hostname: "docker.local", Containers: []models.DockerContainer{container}, } m.CheckDockerHost(host) m.CheckDockerHost(host) resourceID := dockerResourceID(host.ID, container.ID) alertID := fmt.Sprintf("docker-container-state-%s", resourceID) alert, exists := m.activeAlerts[alertID] if !exists { t.Fatalf("expected docker container state alert %s to be raised", alertID) } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical severity from docker defaults, got %s", alert.Level) } } func TestDockerContainerStateRespectsDisableDefault(t *testing.T) { m := NewManager() cfg := m.GetConfig() cfg.DockerDefaults.StateDisableConnectivity = true m.UpdateConfig(cfg) container := models.DockerContainer{ ID: "container-2", Name: "batch", State: "exited", Status: "Exited (0) seconds ago", } host := models.DockerHost{ ID: "host-2", DisplayName: "Docker Host", Hostname: "docker.example", Containers: []models.DockerContainer{container}, } m.CheckDockerHost(host) m.CheckDockerHost(host) resourceID := dockerResourceID(host.ID, container.ID) alertID := fmt.Sprintf("docker-container-state-%s", resourceID) if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("did not expect docker container state alert when defaults disable connectivity") } } func TestDockerContainerMemoryLimitHysteresis(t *testing.T) { m := NewManager() hostID := "host-mem" containerID := "container-mem" hostHigh := models.DockerHost{ ID: hostID, DisplayName: "Docker Host", Hostname: "docker.mem", Containers: []models.DockerContainer{ { ID: containerID, Name: "memory-hog", State: "running", Status: "Up 10 minutes", MemoryUsage: 96 * 1024 * 1024, MemoryLimit: 100 * 1024 * 1024, }, }, } m.CheckDockerHost(hostHigh) resourceID := dockerResourceID(hostID, containerID) alertID := fmt.Sprintf("docker-container-memory-limit-%s", resourceID) if _, exists := m.activeAlerts[alertID]; !exists { t.Fatalf("expected memory limit alert to be raised") } hostLow := models.DockerHost{ ID: hostID, DisplayName: "Docker Host", Hostname: "docker.mem", Containers: []models.DockerContainer{ { ID: containerID, Name: "memory-hog", State: "running", Status: "Up 12 minutes", MemoryUsage: 80 * 1024 * 1024, MemoryLimit: 100 * 1024 * 1024, }, }, } m.CheckDockerHost(hostLow) if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected memory limit alert to clear after usage dropped below hysteresis threshold") } } func TestDockerContainerDiskUsageAlert(t *testing.T) { m := NewManager() cfg := m.GetConfig() cfg.Enabled = true cfg.TimeThreshold = 0 if cfg.TimeThresholds == nil { cfg.TimeThresholds = make(map[string]int) } cfg.TimeThresholds["docker"] = 0 cfg.TimeThresholds["guest"] = 0 cfg.DockerDefaults.Disk = HysteresisThreshold{Trigger: 75, Clear: 65} m.UpdateConfig(cfg) const gib = 1024 * 1024 * 1024 host := models.DockerHost{ ID: "host-disk", DisplayName: "Docker Host", Hostname: "docker.disk", Containers: []models.DockerContainer{ { ID: "container-disk", Name: "disk-hog", State: "running", Status: "Up 5 minutes", WritableLayerBytes: int64(8 * gib), RootFilesystemBytes: int64(10 * gib), }, }, } m.CheckDockerHost(host) resourceID := dockerResourceID(host.ID, host.Containers[0].ID) alertID := fmt.Sprintf("%s-%s", resourceID, "disk") alert, exists := m.activeAlerts[alertID] if !exists { t.Fatalf("expected docker container disk alert %s to be raised", alertID) } if alert.Level != AlertLevelWarning { t.Fatalf("expected warning severity for disk usage alert, got %s", alert.Level) } if alert.Metadata == nil { t.Fatalf("expected disk alert metadata to be populated") } if percent, ok := alert.Metadata["diskPercent"].(float64); !ok || percent < 79.5 || percent > 80.5 { t.Fatalf("expected diskPercent metadata to be ~80%%, got %v", alert.Metadata["diskPercent"]) } if used, ok := alert.Metadata["writableLayerBytes"].(int64); !ok || used != int64(8*gib) { t.Fatalf("expected writableLayerBytes metadata to be %d, got %v", int64(8*gib), alert.Metadata["writableLayerBytes"]) } // Drop usage below the clear threshold and ensure the alert resolves. host.Containers[0].WritableLayerBytes = int64(4 * gib) m.CheckDockerHost(host) if _, stillActive := m.activeAlerts[alertID]; stillActive { t.Fatalf("expected docker container disk alert %s to clear after usage dropped", alertID) } } func TestUpdateConfigClampsDockerServiceCriticalGap(t *testing.T) { t.Parallel() m := NewManager() cfg := AlertConfig{ Enabled: true, GuestDefaults: ThresholdConfig{}, NodeDefaults: ThresholdConfig{}, HostDefaults: ThresholdConfig{}, StorageDefault: HysteresisThreshold{}, DockerDefaults: DockerThresholdConfig{ ServiceWarnGapPct: 35, ServiceCritGapPct: 20, }, PMGDefaults: PMGThresholdConfig{}, SnapshotDefaults: SnapshotAlertConfig{}, BackupDefaults: BackupAlertConfig{}, Overrides: make(map[string]ThresholdConfig), Schedule: ScheduleConfig{}, } m.UpdateConfig(cfg) m.mu.RLock() defer m.mu.RUnlock() if m.config.DockerDefaults.ServiceWarnGapPct != 35 { t.Fatalf("expected warning gap to remain 35, got %d", m.config.DockerDefaults.ServiceWarnGapPct) } if m.config.DockerDefaults.ServiceCritGapPct != 35 { t.Fatalf("expected critical gap to be clamped to 35, got %d", m.config.DockerDefaults.ServiceCritGapPct) } } func TestDockerServiceAlertUsesClampedCriticalGap(t *testing.T) { m := NewManager() m.ClearActiveAlerts() cfg := AlertConfig{ Enabled: true, GuestDefaults: ThresholdConfig{}, NodeDefaults: ThresholdConfig{}, HostDefaults: ThresholdConfig{}, StorageDefault: HysteresisThreshold{}, DockerDefaults: DockerThresholdConfig{ ServiceWarnGapPct: 20, ServiceCritGapPct: 5, }, PMGDefaults: PMGThresholdConfig{}, SnapshotDefaults: SnapshotAlertConfig{}, BackupDefaults: BackupAlertConfig{}, Overrides: make(map[string]ThresholdConfig), Schedule: ScheduleConfig{}, } m.UpdateConfig(cfg) host := models.DockerHost{ ID: "docker-host-1", DisplayName: "Docker Host", Hostname: "docker-host.local", Services: []models.DockerService{ { ID: "svc-123", Name: "api", DesiredTasks: 10, RunningTasks: 7, }, }, } m.CheckDockerHost(host) resourceID := dockerServiceResourceID(host.ID, "svc-123", "api") alertID := fmt.Sprintf("docker-service-health-%s", resourceID) alert, exists := m.activeAlerts[alertID] if !exists { t.Fatalf("expected docker service alert %s to be raised", alertID) } if alert.Level != AlertLevelCritical { t.Fatalf("expected critical severity when replicas 7/10, got %s", alert.Level) } if pct, ok := alert.Metadata["percentMissing"].(float64); !ok || math.Abs(pct-30.0) > 0.01 { t.Fatalf("expected percentMissing metadata ~30, got %v", alert.Metadata["percentMissing"]) } } func TestNormalizeDockerIgnoredPrefixes(t *testing.T) { t.Parallel() tests := []struct { name string input []string expected []string }{ { name: "nil input", input: nil, expected: nil, }, { name: "blank entries removed", input: []string{"", " ", "\t"}, expected: nil, }, { name: "trims and deduplicates preserving first occurrence casing", input: []string{" Foo ", "foo", "Bar", " bar ", "Baz"}, expected: []string{"Foo", "Bar", "Baz"}, }, { name: "already normalized list remains unchanged", input: []string{"alpha", "beta"}, expected: []string{"alpha", "beta"}, }, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() got := NormalizeDockerIgnoredPrefixes(tc.input) if !reflect.DeepEqual(got, tc.expected) { t.Fatalf("expected %v, got %v", tc.expected, got) } }) } } func TestCheckDockerHostIgnoredPrefixClearsExistingAlerts(t *testing.T) { m := NewManager() container := models.DockerContainer{ ID: "abc123456789", Name: "runner-job-1", State: "exited", Status: "Exited (1) 10 seconds ago", } host := models.DockerHost{ ID: "docker-host", DisplayName: "Docker Host", Hostname: "docker-host.local", Containers: []models.DockerContainer{container}, } resourceID := dockerResourceID(host.ID, container.ID) stateAlertID := fmt.Sprintf("docker-container-state-%s", resourceID) healthAlertID := fmt.Sprintf("docker-container-health-%s", resourceID) restartAlertID := fmt.Sprintf("docker-container-restart-loop-%s", resourceID) m.mu.Lock() m.config.Enabled = true m.config.DockerIgnoredContainerPrefixes = []string{"runner-"} m.activeAlerts[stateAlertID] = &Alert{ID: stateAlertID, ResourceID: resourceID} m.activeAlerts[healthAlertID] = &Alert{ID: healthAlertID, ResourceID: resourceID} m.activeAlerts[restartAlertID] = &Alert{ID: restartAlertID, ResourceID: resourceID} m.dockerStateConfirm[resourceID] = 2 m.dockerRestartTracking[resourceID] = &dockerRestartRecord{} m.dockerLastExitCode[resourceID] = 137 m.mu.Unlock() m.CheckDockerHost(host) m.mu.RLock() defer m.mu.RUnlock() if _, exists := m.activeAlerts[stateAlertID]; exists { t.Fatalf("expected state alert cleared for ignored container") } if _, exists := m.activeAlerts[healthAlertID]; exists { t.Fatalf("expected health alert cleared for ignored container") } if _, exists := m.activeAlerts[restartAlertID]; exists { t.Fatalf("expected restart alert cleared for ignored container") } if _, exists := m.dockerStateConfirm[resourceID]; exists { t.Fatalf("expected state confirmation tracking cleared") } if _, exists := m.dockerRestartTracking[resourceID]; exists { t.Fatalf("expected restart tracking cleared") } if _, exists := m.dockerLastExitCode[resourceID]; exists { t.Fatalf("expected last exit code cleared") } } func TestUpdateConfigNormalizesDockerIgnoredPrefixes(t *testing.T) { t.Parallel() t.Run("nil input remains nil", func(t *testing.T) { t.Parallel() m := NewManager() m.UpdateConfig(AlertConfig{}) m.mu.RLock() defer m.mu.RUnlock() if m.config.DockerIgnoredContainerPrefixes != nil { t.Fatalf("expected nil prefixes, got %v", m.config.DockerIgnoredContainerPrefixes) } }) t.Run("duplicates trimmed and deduplicated", func(t *testing.T) { t.Parallel() m := NewManager() cfg := AlertConfig{ DockerIgnoredContainerPrefixes: []string{ " Foo ", "foo", "Bar", }, } m.UpdateConfig(cfg) m.mu.RLock() defer m.mu.RUnlock() expected := []string{"Foo", "Bar"} if !reflect.DeepEqual(m.config.DockerIgnoredContainerPrefixes, expected) { t.Fatalf("expected normalized prefixes %v, got %v", expected, m.config.DockerIgnoredContainerPrefixes) } }) } func TestMatchesDockerIgnoredPrefix(t *testing.T) { t.Parallel() tests := []struct { name string containerName string containerID string prefixes []string want bool }{ {name: "empty prefixes", containerName: "runner-123", containerID: "abc", prefixes: nil, want: false}, {name: "match with name", containerName: "runner-123", containerID: "abc", prefixes: []string{"runner-"}, want: true}, {name: "match with id", containerName: "app", containerID: "abc123", prefixes: []string{"abc"}, want: true}, {name: "trimmed comparison", containerName: "runner-job", containerID: "abc", prefixes: []string{" runner- "}, want: true}, {name: "case insensitive", containerName: "Runner-Job", containerID: "abc", prefixes: []string{"runner-"}, want: true}, {name: "no match", containerName: "service", containerID: "xyz", prefixes: []string{"runner-"}, want: false}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := matchesDockerIgnoredPrefix(tc.containerName, tc.containerID, tc.prefixes); got != tc.want { t.Fatalf("matchesDockerIgnoredPrefix(%q, %q, %v) = %v, want %v", tc.containerName, tc.containerID, tc.prefixes, got, tc.want) } }) } } func TestDockerInstanceName(t *testing.T) { t.Parallel() tests := []struct { name string host models.DockerHost want string }{ {name: "uses display name", host: models.DockerHost{DisplayName: "Prod Host"}, want: "Docker:Prod Host"}, {name: "falls back to hostname", host: models.DockerHost{Hostname: "docker.local"}, want: "Docker:docker.local"}, {name: "defaults when empty", host: models.DockerHost{}, want: "Docker"}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := dockerInstanceName(tc.host); got != tc.want { t.Fatalf("dockerInstanceName(%+v) = %q, want %q", tc.host, got, tc.want) } }) } } func TestDockerContainerDisplayName(t *testing.T) { t.Parallel() tests := []struct { name string container models.DockerContainer want string }{ {name: "trims whitespace", container: models.DockerContainer{Name: " app "}, want: "app"}, {name: "strips leading slash", container: models.DockerContainer{Name: "/runner"}, want: "runner"}, {name: "falls back to id truncated", container: models.DockerContainer{ID: "0123456789abcdef"}, want: "0123456789ab"}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := dockerContainerDisplayName(tc.container); got != tc.want { t.Fatalf("dockerContainerDisplayName(%+v) = %q, want %q", tc.container, got, tc.want) } }) } } func TestDockerResourceID(t *testing.T) { t.Parallel() tests := []struct { name string hostID string containerID string want string }{ {name: "both ids present", hostID: "host1", containerID: "abc", want: "docker:host1/abc"}, {name: "missing host id", hostID: "", containerID: "abc", want: "docker:container/abc"}, {name: "missing container id", hostID: "host1", containerID: "", want: "docker:host1"}, {name: "both missing", hostID: "", containerID: "", want: "docker:unknown"}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := dockerResourceID(tc.hostID, tc.containerID); got != tc.want { t.Fatalf("dockerResourceID(%q, %q) = %q, want %q", tc.hostID, tc.containerID, got, tc.want) } }) } } func TestHasKnownFirmwareBug(t *testing.T) { t.Parallel() tests := []struct { name string model string want bool }{ {name: "Samsung 980 with SSD prefix", model: "Samsung SSD 980 1TB", want: true}, {name: "Samsung 980 without SSD prefix", model: "Samsung 980 PRO 2TB", want: true}, {name: "Samsung 990 with SSD prefix", model: "Samsung SSD 990 PRO 2TB", want: true}, {name: "Samsung 990 without SSD prefix", model: "Samsung 990 EVO 1TB", want: true}, {name: "Samsung 980 lowercase", model: "samsung ssd 980 1tb", want: true}, {name: "Samsung 990 mixed case", model: "SAMSUNG 990 PRO", want: true}, {name: "Samsung 970 (not affected)", model: "Samsung SSD 970 EVO Plus", want: false}, {name: "Samsung 870 (not affected)", model: "Samsung 870 QVO", want: false}, {name: "Other manufacturer", model: "WD Blue SN570", want: false}, {name: "Empty model", model: "", want: false}, } for _, tc := range tests { tc := tc t.Run(tc.name, func(t *testing.T) { t.Parallel() if got := hasKnownFirmwareBug(tc.model); got != tc.want { t.Fatalf("hasKnownFirmwareBug(%q) = %v, want %v", tc.model, got, tc.want) } }) } } func TestCheckDiskHealthSkipsSamsung980FalseAlerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() // Samsung 980 reporting FAILED health (firmware bug) but actually healthy disk := proxmox.Disk{ DevPath: "/dev/nvme0n1", Model: "Samsung SSD 980 1TB", Serial: "S649NF0R123456", Type: "nvme", Health: "FAILED", // False report due to firmware bug Wearout: 99, // Drive is actually healthy with 99% life remaining Size: 1000204886016, } // Should not create an alert for health status m.CheckDiskHealth("test-instance", "pve-node1", disk) m.mu.RLock() healthAlertID := "disk-health-test-instance-pve-node1-/dev/nvme0n1" if _, exists := m.activeAlerts[healthAlertID]; exists { m.mu.RUnlock() t.Fatalf("expected no health alert for Samsung 980 with known firmware bug") } m.mu.RUnlock() // Now test that wearout alerts still work for these drives disk.Wearout = 5 // Low wearout should still trigger alert m.CheckDiskHealth("test-instance", "pve-node1", disk) m.mu.RLock() wearoutAlertID := "disk-wearout-test-instance-pve-node1-/dev/nvme0n1" if _, exists := m.activeAlerts[wearoutAlertID]; !exists { m.mu.RUnlock() t.Fatalf("expected wearout alert to still work for Samsung 980") } m.mu.RUnlock() } func TestCheckDiskHealthClearsExistingSamsung980Alerts(t *testing.T) { m := NewManager() m.ClearActiveAlerts() disk := proxmox.Disk{ DevPath: "/dev/nvme0n1", Model: "Samsung SSD 990 PRO 2TB", Serial: "S6Z0NF0R654321", Type: "nvme", Health: "FAILED", Wearout: 98, Size: 2000398934016, } alertID := "disk-health-test-instance-pve-node1-/dev/nvme0n1" // Manually create an existing alert (simulating alert from before the fix) m.mu.Lock() m.activeAlerts[alertID] = &Alert{ ID: alertID, Type: "disk-health", Level: AlertLevelCritical, ResourceID: "pve-node1-/dev/nvme0n1", ResourceName: "Samsung SSD 990 PRO 2TB (/dev/nvme0n1)", Node: "pve-node1", Instance: "test-instance", Message: "Disk health check failed: FAILED", } m.mu.Unlock() // Check disk health - should clear the existing false alert m.CheckDiskHealth("test-instance", "pve-node1", disk) m.mu.RLock() defer m.mu.RUnlock() if _, exists := m.activeAlerts[alertID]; exists { t.Fatalf("expected existing Samsung 990 health alert to be cleared") } } func TestDisableAllStorageClearsExistingAlerts(t *testing.T) { m := NewManager() storageID := "local-lvm" // Start with configuration that allows storage alerts initialConfig := AlertConfig{ Enabled: true, DisableAllStorage: false, StorageDefault: HysteresisThreshold{Trigger: 80, Clear: 75}, TimeThreshold: 0, TimeThresholds: map[string]int{}, NodeDefaults: ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 80, Clear: 75}, Memory: &HysteresisThreshold{Trigger: 85, Clear: 80}, Disk: &HysteresisThreshold{Trigger: 90, Clear: 85}, }, GuestDefaults: ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 80, Clear: 75}, }, Overrides: make(map[string]ThresholdConfig), } m.UpdateConfig(initialConfig) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.ActivationState = ActivationActive m.mu.Unlock() var dispatched []*Alert done := make(chan struct{}, 1) var resolved []string resolvedDone := make(chan struct{}, 1) m.SetAlertCallback(func(alert *Alert) { dispatched = append(dispatched, alert) select { case done <- struct{}{}: default: } }) m.SetResolvedCallback(func(alertID string) { resolved = append(resolved, alertID) select { case resolvedDone <- struct{}{}: default: } }) storage := models.Storage{ ID: storageID, Name: "local-lvm", Usage: 90.0, Status: "available", } // Initial check should trigger an alert m.CheckStorage(storage) select { case <-done: case <-time.After(100 * time.Millisecond): t.Fatalf("did not receive initial alert dispatch") } if len(dispatched) != 1 { t.Fatalf("expected 1 alert before disabling storage, got %d", len(dispatched)) } // Apply config with DisableAllStorage enabled disabledConfig := initialConfig disabledConfig.DisableAllStorage = true m.UpdateConfig(disabledConfig) m.mu.Lock() m.config.TimeThreshold = 0 m.config.TimeThresholds = map[string]int{} m.config.ActivationState = ActivationActive m.mu.Unlock() // Clear dispatched slice to capture only post-disable notifications dispatched = dispatched[:0] done = make(chan struct{}, 1) // Re-run CheckStorage with high usage; no alert should be dispatched m.CheckStorage(storage) select { case <-done: t.Fatalf("expected no alerts after disabling all storage, but callback fired") case <-time.After(100 * time.Millisecond): // No callback fired as expected } // Active alerts should be cleared by reevaluateActiveAlertsLocked m.mu.RLock() activeCount := len(m.activeAlerts) m.mu.RUnlock() if activeCount != 0 { t.Fatalf("expected active alerts to be cleared after disabling all storage, got %d", activeCount) } // Resolved callback should have fired select { case <-resolvedDone: case <-time.After(100 * time.Millisecond): t.Fatalf("expected resolved callback to fire after disabling all storage") } expectedAlertID := fmt.Sprintf("%s-usage", storageID) if len(resolved) != 1 || resolved[0] != expectedAlertID { t.Fatalf("expected resolved callback for %s, got %v", expectedAlertID, resolved) } // Pending alert should be cleared m.mu.RLock() _, isPending := m.pendingAlerts[expectedAlertID] m.mu.RUnlock() if isPending { t.Fatalf("expected pending alert entry to be cleared after disabling all storage") } } func TestUpdateConfigPreservesZeroDockerThresholds(t *testing.T) { t.Helper() m := NewManager() config := m.GetConfig() config.DockerDefaults.Memory = HysteresisThreshold{Trigger: 0, Clear: 0} m.UpdateConfig(config) m.mu.RLock() defer m.mu.RUnlock() if m.config.DockerDefaults.Memory.Trigger != 0 { t.Fatalf("expected docker memory trigger to remain 0 when disabled, got %.1f", m.config.DockerDefaults.Memory.Trigger) } if m.config.DockerDefaults.Memory.Clear != 0 { t.Fatalf("expected docker memory clear to remain 0 when disabled, got %.1f", m.config.DockerDefaults.Memory.Clear) } } func TestReevaluateClearsDockerContainerAlertWhenOverrideDisabled(t *testing.T) { m := NewManager() resourceID := "docker:host-1/container-1" alertID := resourceID + "-memory" resolved := make(chan string, 1) m.SetResolvedCallback(func(id string) { resolved <- id }) m.mu.Lock() m.activeAlerts[alertID] = &Alert{ ID: alertID, Type: "memory", ResourceID: resourceID, ResourceName: "qbittorrent", Instance: "Docker", Metadata: map[string]interface{}{ "resourceType": "Docker Container", }, Threshold: 80, Value: 90, } m.mu.Unlock() config := m.GetConfig() config.Overrides = map[string]ThresholdConfig{ resourceID: { Disabled: true, }, } config.ActivationState = ActivationActive m.UpdateConfig(config) select { case got := <-resolved: if got != alertID { t.Fatalf("resolved callback fired for unexpected alert %s", got) } case <-time.After(200 * time.Millisecond): t.Fatalf("expected alert to be resolved when docker container override is disabled") } m.mu.RLock() _, exists := m.activeAlerts[alertID] m.mu.RUnlock() if exists { t.Fatalf("expected docker container alert to be cleared when override is disabled") } } func TestReevaluateClearsDockerContainerAlertWhenIgnoredPrefixAdded(t *testing.T) { m := NewManager() resourceID := "docker:host-2/container-abc123" alertID := resourceID + "-cpu" resolved := make(chan string, 1) m.SetResolvedCallback(func(id string) { resolved <- id }) m.mu.Lock() m.activeAlerts[alertID] = &Alert{ ID: alertID, Type: "cpu", ResourceID: resourceID, ResourceName: "qbittorrentvpn", Instance: "Docker", Metadata: map[string]interface{}{ "resourceType": "Docker Container", "containerId": "abc123", "containerName": "qbittorrentvpn", }, Threshold: 80, Value: 95, } m.mu.Unlock() config := m.GetConfig() config.DockerIgnoredContainerPrefixes = []string{"qbit"} config.ActivationState = ActivationActive m.UpdateConfig(config) select { case got := <-resolved: if got != alertID { t.Fatalf("resolved callback fired for unexpected alert %s", got) } case <-time.After(200 * time.Millisecond): t.Fatalf("expected alert to be resolved after adding ignored prefix") } m.mu.RLock() _, exists := m.activeAlerts[alertID] m.mu.RUnlock() if exists { t.Fatalf("expected docker container alert to be cleared when ignored prefix is configured") } } func TestBuildGuestKey(t *testing.T) { t.Parallel() tests := []struct { name string instance string node string vmid int want string }{ { name: "different instance and node", instance: "cluster-1", node: "pve-node", vmid: 100, want: "cluster-1-pve-node-100", }, { name: "same instance and node", instance: "pve-node", node: "pve-node", vmid: 200, want: "pve-node-200", }, { name: "empty instance uses node", instance: "", node: "pve-node", vmid: 300, want: "pve-node-300", }, { name: "whitespace instance uses node", instance: " ", node: "pve-node", vmid: 400, want: "pve-node-400", }, { name: "instance with whitespace trimmed", instance: " cluster-1 ", node: "pve-node", vmid: 500, want: "cluster-1-pve-node-500", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() got := BuildGuestKey(tt.instance, tt.node, tt.vmid) if got != tt.want { t.Errorf("BuildGuestKey(%q, %q, %d) = %q, want %q", tt.instance, tt.node, tt.vmid, got, tt.want) } }) } } func TestCheckFlapping(t *testing.T) { t.Parallel() tests := []struct { name string flappingEnabled bool threshold int windowSeconds int cooldownMinutes int historyEntries int // number of state changes to simulate before the test call expectFlapping bool expectNewFlapping bool // should this trigger a new flapping detection (vs already flapping) }{ { name: "disabled returns false", flappingEnabled: false, threshold: 5, windowSeconds: 300, historyEntries: 10, // way over threshold expectFlapping: false, }, { name: "below threshold returns false", flappingEnabled: true, threshold: 5, windowSeconds: 300, historyEntries: 2, // only 2 + 1 (test call) = 3 < 5 expectFlapping: false, }, { name: "at threshold triggers new flapping", flappingEnabled: true, threshold: 5, windowSeconds: 300, cooldownMinutes: 15, historyEntries: 4, // 4 + 1 (test call) = 5 == threshold expectFlapping: true, expectNewFlapping: true, }, { name: "above threshold triggers flapping", flappingEnabled: true, threshold: 5, windowSeconds: 300, cooldownMinutes: 15, historyEntries: 6, // 6 + 1 = 7 > 5 expectFlapping: true, expectNewFlapping: true, }, { name: "single state change below threshold", flappingEnabled: true, threshold: 5, windowSeconds: 300, historyEntries: 0, // only the test call = 1 < 5 expectFlapping: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() m := NewManager() // Configure flapping settings m.mu.Lock() m.config.FlappingEnabled = tt.flappingEnabled m.config.FlappingThreshold = tt.threshold m.config.FlappingWindowSeconds = tt.windowSeconds m.config.FlappingCooldownMinutes = tt.cooldownMinutes alertID := "test-alert-" + tt.name // Add history entries within the time window now := time.Now() for i := 0; i < tt.historyEntries; i++ { m.flappingHistory[alertID] = append(m.flappingHistory[alertID], now.Add(-time.Duration(i)*time.Second)) } m.mu.Unlock() // Call checkFlapping m.mu.Lock() result := m.checkFlapping(alertID) m.mu.Unlock() if result != tt.expectFlapping { t.Errorf("checkFlapping() = %v, want %v", result, tt.expectFlapping) } // Check if flapping was newly detected m.mu.RLock() isFlappingActive := m.flappingActive[alertID] _, hasSuppression := m.suppressedUntil[alertID] m.mu.RUnlock() if tt.expectNewFlapping { if !isFlappingActive { t.Errorf("expected flappingActive[%s] to be true", alertID) } if !hasSuppression { t.Errorf("expected suppressedUntil[%s] to be set", alertID) } } }) } } func TestCheckFlappingAlreadyFlapping(t *testing.T) { t.Parallel() m := NewManager() alertID := "already-flapping-alert" m.mu.Lock() m.config.FlappingEnabled = true m.config.FlappingThreshold = 3 m.config.FlappingWindowSeconds = 300 m.config.FlappingCooldownMinutes = 15 // Pre-set flapping state m.flappingActive[alertID] = true existingSuppression := time.Now().Add(10 * time.Minute) m.suppressedUntil[alertID] = existingSuppression // Add history to exceed threshold now := time.Now() m.flappingHistory[alertID] = []time.Time{ now.Add(-10 * time.Second), now.Add(-5 * time.Second), } m.mu.Unlock() // Call checkFlapping - should return true but NOT update suppression m.mu.Lock() result := m.checkFlapping(alertID) m.mu.Unlock() if !result { t.Errorf("checkFlapping() = false, want true for already flapping alert") } // Verify suppression time was NOT updated (existing suppression should remain) m.mu.RLock() currentSuppression := m.suppressedUntil[alertID] m.mu.RUnlock() if !currentSuppression.Equal(existingSuppression) { t.Errorf("suppressedUntil was updated from %v to %v; should remain unchanged for already-flapping alert", existingSuppression, currentSuppression) } } func TestCheckFlappingWindowExpiry(t *testing.T) { t.Parallel() m := NewManager() alertID := "window-expiry-alert" m.mu.Lock() m.config.FlappingEnabled = true m.config.FlappingThreshold = 3 m.config.FlappingWindowSeconds = 60 // 1 minute window // Add old history entries outside the window now := time.Now() m.flappingHistory[alertID] = []time.Time{ now.Add(-5 * time.Minute), // outside 1 minute window now.Add(-4 * time.Minute), // outside 1 minute window now.Add(-3 * time.Minute), // outside 1 minute window now.Add(-2 * time.Minute), // outside 1 minute window } m.mu.Unlock() // Call checkFlapping - old entries should be pruned m.mu.Lock() result := m.checkFlapping(alertID) historyLen := len(m.flappingHistory[alertID]) m.mu.Unlock() if result { t.Errorf("checkFlapping() = true, want false (old entries should be pruned)") } // Only the current call should remain in history if historyLen != 1 { t.Errorf("history length = %d, want 1 (old entries should be pruned)", historyLen) } } func TestGetGlobalMetricTimeThreshold(t *testing.T) { t.Parallel() tests := []struct { name string metricTimeThresholds map[string]map[string]int metricType string wantDelay int wantFound bool }{ { name: "empty MetricTimeThresholds returns false", metricTimeThresholds: nil, metricType: "cpu", wantDelay: 0, wantFound: false, }, { name: "no all key returns false", metricTimeThresholds: map[string]map[string]int{"specific": {"cpu": 60}}, metricType: "cpu", wantDelay: 0, wantFound: false, }, { name: "empty all map returns false", metricTimeThresholds: map[string]map[string]int{"all": {}}, metricType: "cpu", wantDelay: 0, wantFound: false, }, { name: "empty metricType returns false", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 60}}, metricType: "", wantDelay: 0, wantFound: false, }, { name: "whitespace metricType returns false", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 60}}, metricType: " ", wantDelay: 0, wantFound: false, }, { name: "direct metric match", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 120, "memory": 90}}, metricType: "cpu", wantDelay: 120, wantFound: true, }, { name: "metric match case insensitive", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 120}}, metricType: "CPU", wantDelay: 120, wantFound: true, }, { name: "metric match with whitespace", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 120}}, metricType: " cpu ", wantDelay: 120, wantFound: true, }, { name: "default fallback", metricTimeThresholds: map[string]map[string]int{"all": {"default": 30}}, metricType: "unknown", wantDelay: 30, wantFound: true, }, { name: "_default fallback", metricTimeThresholds: map[string]map[string]int{"all": {"_default": 45}}, metricType: "unknown", wantDelay: 45, wantFound: true, }, { name: "wildcard fallback", metricTimeThresholds: map[string]map[string]int{"all": {"*": 15}}, metricType: "unknown", wantDelay: 15, wantFound: true, }, { name: "direct match takes precedence over default", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 120, "default": 30}}, metricType: "cpu", wantDelay: 120, wantFound: true, }, { name: "no match and no fallback returns false", metricTimeThresholds: map[string]map[string]int{"all": {"cpu": 120, "memory": 90}}, metricType: "disk", wantDelay: 0, wantFound: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.MetricTimeThresholds = tt.metricTimeThresholds m.mu.Unlock() m.mu.RLock() gotDelay, gotFound := m.getGlobalMetricTimeThreshold(tt.metricType) m.mu.RUnlock() if gotDelay != tt.wantDelay { t.Errorf("getGlobalMetricTimeThreshold() delay = %d, want %d", gotDelay, tt.wantDelay) } if gotFound != tt.wantFound { t.Errorf("getGlobalMetricTimeThreshold() found = %v, want %v", gotFound, tt.wantFound) } }) } } func TestGetBaseTimeThreshold(t *testing.T) { t.Parallel() tests := []struct { name string timeThresholds map[string]int timeThreshold int // global fallback resourceType string wantDelay int wantFound bool }{ { name: "nil TimeThresholds returns global TimeThreshold", timeThresholds: nil, timeThreshold: 60, resourceType: "guest", wantDelay: 60, wantFound: false, }, { name: "direct resource type match", timeThresholds: map[string]int{"guest": 120, "node": 90}, timeThreshold: 60, resourceType: "guest", wantDelay: 120, wantFound: true, }, { name: "canonical key match for vm", timeThresholds: map[string]int{"guest": 120}, timeThreshold: 60, resourceType: "vm", wantDelay: 120, wantFound: true, }, { name: "canonical key match for container", timeThresholds: map[string]int{"guest": 120}, timeThreshold: 60, resourceType: "container", wantDelay: 120, wantFound: true, }, { name: "all fallback when no specific match", timeThresholds: map[string]int{"all": 45}, timeThreshold: 60, resourceType: "storage", wantDelay: 45, wantFound: false, // "all" returns found=false }, { name: "specific match takes precedence over all", timeThresholds: map[string]int{"storage": 30, "all": 45}, timeThreshold: 60, resourceType: "storage", wantDelay: 30, wantFound: true, }, { name: "no match and no all returns global threshold", timeThresholds: map[string]int{"guest": 120}, timeThreshold: 60, resourceType: "storage", wantDelay: 60, wantFound: false, }, { name: "empty TimeThresholds returns global threshold", timeThresholds: map[string]int{}, timeThreshold: 60, resourceType: "guest", wantDelay: 60, wantFound: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.TimeThresholds = tt.timeThresholds m.config.TimeThreshold = tt.timeThreshold m.mu.Unlock() m.mu.RLock() gotDelay, gotFound := m.getBaseTimeThreshold(tt.resourceType) m.mu.RUnlock() if gotDelay != tt.wantDelay { t.Errorf("getBaseTimeThreshold() delay = %d, want %d", gotDelay, tt.wantDelay) } if gotFound != tt.wantFound { t.Errorf("getBaseTimeThreshold() found = %v, want %v", gotFound, tt.wantFound) } }) } } func TestGetMetricTimeThreshold(t *testing.T) { t.Parallel() tests := []struct { name string metricTimeThresholds map[string]map[string]int resourceType string metricType string wantDelay int wantFound bool }{ { name: "empty MetricTimeThresholds returns false", metricTimeThresholds: nil, resourceType: "guest", metricType: "cpu", wantDelay: 0, wantFound: false, }, { name: "empty metricType returns false", metricTimeThresholds: map[string]map[string]int{"guest": {"cpu": 60}}, resourceType: "guest", metricType: "", wantDelay: 0, wantFound: false, }, { name: "whitespace metricType returns false", metricTimeThresholds: map[string]map[string]int{"guest": {"cpu": 60}}, resourceType: "guest", metricType: " ", wantDelay: 0, wantFound: false, }, { name: "direct match on resourceType and metricType", metricTimeThresholds: map[string]map[string]int{"guest": {"cpu": 120, "memory": 90}}, resourceType: "guest", metricType: "cpu", wantDelay: 120, wantFound: true, }, { name: "canonical key match vm to guest", metricTimeThresholds: map[string]map[string]int{"guest": {"cpu": 120}}, resourceType: "vm", metricType: "cpu", wantDelay: 120, wantFound: true, }, { name: "canonical key match container to guest", metricTimeThresholds: map[string]map[string]int{"guest": {"memory": 90}}, resourceType: "container", metricType: "memory", wantDelay: 90, wantFound: true, }, { name: "default fallback within resourceType", metricTimeThresholds: map[string]map[string]int{"guest": {"default": 30}}, resourceType: "guest", metricType: "unknown", wantDelay: 30, wantFound: true, }, { name: "_default fallback within resourceType", metricTimeThresholds: map[string]map[string]int{"guest": {"_default": 45}}, resourceType: "guest", metricType: "unknown", wantDelay: 45, wantFound: true, }, { name: "wildcard fallback within resourceType", metricTimeThresholds: map[string]map[string]int{"guest": {"*": 15}}, resourceType: "guest", metricType: "unknown", wantDelay: 15, wantFound: true, }, { name: "direct match takes precedence over default", metricTimeThresholds: map[string]map[string]int{"guest": {"cpu": 120, "default": 30}}, resourceType: "guest", metricType: "cpu", wantDelay: 120, wantFound: true, }, { name: "no match for resourceType returns false", metricTimeThresholds: map[string]map[string]int{"node": {"cpu": 60}}, resourceType: "guest", metricType: "cpu", wantDelay: 0, wantFound: false, }, { name: "empty perType map skipped", metricTimeThresholds: map[string]map[string]int{"guest": {}}, resourceType: "guest", metricType: "cpu", wantDelay: 0, wantFound: false, }, { name: "metricType case insensitive", metricTimeThresholds: map[string]map[string]int{"guest": {"cpu": 120}}, resourceType: "guest", metricType: "CPU", wantDelay: 120, wantFound: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.MetricTimeThresholds = tt.metricTimeThresholds m.mu.Unlock() m.mu.RLock() gotDelay, gotFound := m.getMetricTimeThreshold(tt.resourceType, tt.metricType) m.mu.RUnlock() if gotDelay != tt.wantDelay { t.Errorf("getMetricTimeThreshold() delay = %d, want %d", gotDelay, tt.wantDelay) } if gotFound != tt.wantFound { t.Errorf("getMetricTimeThreshold() found = %v, want %v", gotFound, tt.wantFound) } }) } } func TestCheckRateLimit(t *testing.T) { t.Parallel() t.Run("no rate limit when MaxAlertsHour is zero", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = 0 m.mu.Unlock() m.mu.Lock() result := m.checkRateLimit("test-alert") m.mu.Unlock() if !result { t.Errorf("checkRateLimit() = false, want true when MaxAlertsHour is 0") } }) t.Run("no rate limit when MaxAlertsHour is negative", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = -1 m.mu.Unlock() m.mu.Lock() result := m.checkRateLimit("test-alert") m.mu.Unlock() if !result { t.Errorf("checkRateLimit() = false, want true when MaxAlertsHour is negative") } }) t.Run("allows alerts under rate limit", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = 5 m.mu.Unlock() // First 5 alerts should be allowed for i := 0; i < 5; i++ { m.mu.Lock() result := m.checkRateLimit("test-alert") m.mu.Unlock() if !result { t.Errorf("checkRateLimit() call %d = false, want true (under limit)", i+1) } } }) t.Run("blocks alerts at rate limit", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = 3 m.mu.Unlock() // Use up the rate limit for i := 0; i < 3; i++ { m.mu.Lock() _ = m.checkRateLimit("test-alert") m.mu.Unlock() } // Fourth alert should be blocked m.mu.Lock() result := m.checkRateLimit("test-alert") m.mu.Unlock() if result { t.Errorf("checkRateLimit() = true, want false (at rate limit)") } }) t.Run("different alert IDs have separate limits", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = 2 m.mu.Unlock() // Use up limit for alert-1 for i := 0; i < 2; i++ { m.mu.Lock() _ = m.checkRateLimit("alert-1") m.mu.Unlock() } // alert-2 should still be allowed m.mu.Lock() result := m.checkRateLimit("alert-2") m.mu.Unlock() if !result { t.Errorf("checkRateLimit(alert-2) = false, want true (separate limit)") } }) t.Run("old entries are cleaned up", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = 2 // Pre-populate with old entries (more than 1 hour ago) oldTime := time.Now().Add(-2 * time.Hour) m.alertRateLimit["test-alert"] = []time.Time{oldTime, oldTime} m.mu.Unlock() // Should be allowed because old entries are cleaned up m.mu.Lock() result := m.checkRateLimit("test-alert") m.mu.Unlock() if !result { t.Errorf("checkRateLimit() = false, want true (old entries should be cleaned)") } }) t.Run("mixed old and recent entries", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.MaxAlertsHour = 2 // Pre-populate with 1 old and 1 recent entry oldTime := time.Now().Add(-2 * time.Hour) recentTime := time.Now().Add(-30 * time.Minute) m.alertRateLimit["test-alert"] = []time.Time{oldTime, recentTime} m.mu.Unlock() // First call should be allowed (1 recent + 1 new = 2) m.mu.Lock() result1 := m.checkRateLimit("test-alert") m.mu.Unlock() if !result1 { t.Errorf("checkRateLimit() call 1 = false, want true") } // Second call should be blocked (2 recent + 1 new would exceed 2) m.mu.Lock() result2 := m.checkRateLimit("test-alert") m.mu.Unlock() if result2 { t.Errorf("checkRateLimit() call 2 = true, want false (at limit)") } }) } func TestApplyRelaxedGuestThresholds(t *testing.T) { t.Parallel() t.Run("nil thresholds get defaults", func(t *testing.T) { t.Parallel() cfg := ThresholdConfig{ CPU: nil, Memory: nil, Disk: nil, } result := applyRelaxedGuestThresholds(cfg) if result.CPU == nil { t.Fatal("expected CPU threshold to be set") } if result.CPU.Trigger != 95 { t.Errorf("CPU.Trigger = %v, want 95", result.CPU.Trigger) } if result.CPU.Clear != 90 { t.Errorf("CPU.Clear = %v, want 90", result.CPU.Clear) } if result.Memory == nil { t.Fatal("expected Memory threshold to be set") } if result.Memory.Trigger != 92 { t.Errorf("Memory.Trigger = %v, want 92", result.Memory.Trigger) } if result.Disk == nil { t.Fatal("expected Disk threshold to be set") } if result.Disk.Trigger != 95 { t.Errorf("Disk.Trigger = %v, want 95", result.Disk.Trigger) } }) t.Run("low thresholds raised to minimum", func(t *testing.T) { t.Parallel() cfg := ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 50, Clear: 45}, Memory: &HysteresisThreshold{Trigger: 60, Clear: 55}, Disk: &HysteresisThreshold{Trigger: 70, Clear: 65}, } result := applyRelaxedGuestThresholds(cfg) if result.CPU.Trigger != 95 { t.Errorf("CPU.Trigger = %v, want 95 (raised to minimum)", result.CPU.Trigger) } if result.Memory.Trigger != 92 { t.Errorf("Memory.Trigger = %v, want 92 (raised to minimum)", result.Memory.Trigger) } if result.Disk.Trigger != 95 { t.Errorf("Disk.Trigger = %v, want 95 (raised to minimum)", result.Disk.Trigger) } }) t.Run("high thresholds unchanged", func(t *testing.T) { t.Parallel() cfg := ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 98, Clear: 93}, Memory: &HysteresisThreshold{Trigger: 95, Clear: 90}, Disk: &HysteresisThreshold{Trigger: 99, Clear: 94}, } result := applyRelaxedGuestThresholds(cfg) if result.CPU.Trigger != 98 { t.Errorf("CPU.Trigger = %v, want 98 (unchanged)", result.CPU.Trigger) } if result.Memory.Trigger != 95 { t.Errorf("Memory.Trigger = %v, want 95 (unchanged)", result.Memory.Trigger) } if result.Disk.Trigger != 99 { t.Errorf("Disk.Trigger = %v, want 99 (unchanged)", result.Disk.Trigger) } }) t.Run("clear adjusted when too close to trigger", func(t *testing.T) { t.Parallel() cfg := ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 95, Clear: 96}, // Clear >= Trigger } result := applyRelaxedGuestThresholds(cfg) if result.CPU.Clear >= result.CPU.Trigger { t.Errorf("CPU.Clear = %v should be less than Trigger = %v", result.CPU.Clear, result.CPU.Trigger) } if result.CPU.Clear != 90 { t.Errorf("CPU.Clear = %v, want 90 (Trigger - 5)", result.CPU.Clear) } }) t.Run("clear clamped at zero when it would go negative", func(t *testing.T) { t.Parallel() // Create a threshold where Trigger is above min but Clear would go negative // The adjust function sets Clear = Trigger - 5 if Clear >= Trigger // Then clamps to 0 if Clear < 0 // Since all triggers get raised to 95/92/95, the negative clamp path // won't be hit in normal use. Test the logic directly with a config // that has Trigger exactly at minimum and Clear at minimum cfg := ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 95, Clear: 3}, } result := applyRelaxedGuestThresholds(cfg) // Clear at 3 is valid (less than Trigger 95), should stay at 3 if result.CPU.Trigger != 95 { t.Errorf("CPU.Trigger = %v, want 95", result.CPU.Trigger) } if result.CPU.Clear != 3 { t.Errorf("CPU.Clear = %v, want 3 (unchanged since < Trigger)", result.CPU.Clear) } }) t.Run("original config unchanged", func(t *testing.T) { t.Parallel() original := ThresholdConfig{ CPU: &HysteresisThreshold{Trigger: 50, Clear: 45}, } _ = applyRelaxedGuestThresholds(original) // Original should be unchanged if original.CPU.Trigger != 50 { t.Errorf("original CPU.Trigger = %v, want 50 (should be unchanged)", original.CPU.Trigger) } }) } func TestShouldNotifyAfterCooldown(t *testing.T) { t.Parallel() t.Run("cooldown disabled allows notification", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.Cooldown = 0 m.mu.Unlock() alert := &Alert{ ID: "test-alert", LastNotified: nil, } if !m.shouldNotifyAfterCooldown(alert) { t.Error("expected true when cooldown is 0") } }) t.Run("negative cooldown allows notification", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.Cooldown = -5 m.mu.Unlock() now := time.Now() alert := &Alert{ ID: "test-alert", LastNotified: &now, } if !m.shouldNotifyAfterCooldown(alert) { t.Error("expected true when cooldown is negative") } }) t.Run("first notification allowed when never notified", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.Cooldown = 30 // 30 minutes m.mu.Unlock() alert := &Alert{ ID: "test-alert", LastNotified: nil, } if !m.shouldNotifyAfterCooldown(alert) { t.Error("expected true when alert has never been notified") } }) t.Run("notification blocked during cooldown period", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.Cooldown = 30 // 30 minutes m.mu.Unlock() lastNotified := time.Now().Add(-10 * time.Minute) // Notified 10 minutes ago alert := &Alert{ ID: "test-alert", LastNotified: &lastNotified, } if m.shouldNotifyAfterCooldown(alert) { t.Error("expected false when still in cooldown period") } }) t.Run("notification allowed after cooldown expires", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.Cooldown = 30 // 30 minutes m.mu.Unlock() lastNotified := time.Now().Add(-45 * time.Minute) // Notified 45 minutes ago alert := &Alert{ ID: "test-alert", LastNotified: &lastNotified, } if !m.shouldNotifyAfterCooldown(alert) { t.Error("expected true after cooldown period expires") } }) t.Run("notification allowed at exact cooldown boundary", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.config.Schedule.Cooldown = 30 // 30 minutes m.mu.Unlock() lastNotified := time.Now().Add(-30 * time.Minute) // Exactly 30 minutes ago alert := &Alert{ ID: "test-alert", LastNotified: &lastNotified, } if !m.shouldNotifyAfterCooldown(alert) { t.Error("expected true at exact cooldown boundary (>=)") } }) } func TestDockerServiceDisplayName(t *testing.T) { t.Parallel() tests := []struct { name string service models.DockerService expected string }{ { name: "returns name when present", service: models.DockerService{Name: "my-service", ID: "abc123456789xyz"}, expected: "my-service", }, { name: "returns trimmed name", service: models.DockerService{Name: " my-service ", ID: "abc123456789xyz"}, expected: "my-service", }, { name: "returns truncated ID when name is empty", service: models.DockerService{Name: "", ID: "abc123456789xyz"}, expected: "abc123456789", }, { name: "returns full short ID when less than 12 chars", service: models.DockerService{Name: "", ID: "abc123"}, expected: "abc123", }, { name: "returns trimmed ID", service: models.DockerService{Name: "", ID: " abc123456789xyz "}, expected: "abc123456789", }, { name: "returns 'service' when both name and ID empty", service: models.DockerService{Name: "", ID: ""}, expected: "service", }, { name: "returns 'service' when both whitespace only", service: models.DockerService{Name: " ", ID: " "}, expected: "service", }, { name: "prefers name over ID", service: models.DockerService{Name: "preferred", ID: "not-this-id"}, expected: "preferred", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := dockerServiceDisplayName(tt.service) if result != tt.expected { t.Errorf("dockerServiceDisplayName(%+v) = %q, want %q", tt.service, result, tt.expected) } }) } } func TestDockerServiceResourceID(t *testing.T) { t.Parallel() tests := []struct { name string hostID string serviceID string serviceName string expected string }{ { name: "with host and service ID", hostID: "host-1", serviceID: "svc-123", serviceName: "my-service", expected: "docker:host-1/service/svc-123", }, { name: "without host ID uses service prefix only", hostID: "", serviceID: "svc-123", serviceName: "my-service", expected: "docker-service:svc-123", }, { name: "whitespace host ID treated as empty", hostID: " ", serviceID: "svc-123", serviceName: "my-service", expected: "docker-service:svc-123", }, { name: "derives ID from service name when ID empty", hostID: "host-1", serviceID: "", serviceName: "My Service", expected: "docker:host-1/service/my-service", }, { name: "special chars in name replaced with dash", hostID: "host-1", serviceID: "", serviceName: "my/service:v1.0", expected: "docker:host-1/service/my-service-v1-0", }, { name: "backslash and colon replaced", hostID: "host-1", serviceID: "", serviceName: "path\\to:service", expected: "docker:host-1/service/path-to-service", }, { name: "preserves alphanumeric and underscore", hostID: "host-1", serviceID: "", serviceName: "my_service_123", expected: "docker:host-1/service/my_service_123", }, { name: "preserves hyphens", hostID: "host-1", serviceID: "", serviceName: "my-service-name", expected: "docker:host-1/service/my-service-name", }, { name: "trims leading/trailing dashes and underscores", hostID: "host-1", serviceID: "", serviceName: "---my-service___", expected: "docker:host-1/service/my-service", }, { name: "truncates long derived ID to 32 chars", hostID: "host-1", serviceID: "", serviceName: "this-is-a-very-long-service-name-that-exceeds-the-limit", expected: "docker:host-1/service/this-is-a-very-long-service-name", }, { name: "uses 'service' when name is all special chars", hostID: "host-1", serviceID: "", serviceName: "!!!@@@###", expected: "docker:host-1/service/service", }, { name: "uses 'service' when both ID and name empty", hostID: "host-1", serviceID: "", serviceName: "", expected: "docker:host-1/service/service", }, { name: "uses 'service' when both ID and name whitespace", hostID: "host-1", serviceID: " ", serviceName: " ", expected: "docker:host-1/service/service", }, { name: "no host and derived name", hostID: "", serviceID: "", serviceName: "webserver", expected: "docker-service:webserver", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() result := dockerServiceResourceID(tt.hostID, tt.serviceID, tt.serviceName) if result != tt.expected { t.Errorf("dockerServiceResourceID(%q, %q, %q) = %q, want %q", tt.hostID, tt.serviceID, tt.serviceName, result, tt.expected) } }) } } func TestClearStorageOfflineAlert(t *testing.T) { t.Parallel() t.Run("clears existing offline alert", func(t *testing.T) { t.Parallel() m := NewManager() storage := models.Storage{ ID: "storage-1", Name: "local-lvm", Node: "pve1", } alertID := fmt.Sprintf("storage-offline-%s", storage.ID) // Create an existing offline alert m.mu.Lock() m.activeAlerts[alertID] = &Alert{ ID: alertID, Type: "storage-offline", Level: "critical", StartTime: time.Now().Add(-10 * time.Minute), } m.offlineConfirmations[storage.ID] = 3 m.mu.Unlock() var resolvedID string m.SetResolvedCallback(func(id string) { resolvedID = id }) m.clearStorageOfflineAlert(storage) m.mu.RLock() _, alertExists := m.activeAlerts[alertID] _, confirmExists := m.offlineConfirmations[storage.ID] m.mu.RUnlock() if alertExists { t.Error("expected alert to be cleared") } if confirmExists { t.Error("expected offline confirmation to be cleared") } if resolvedID != alertID { t.Errorf("expected resolved callback with %q, got %q", alertID, resolvedID) } }) t.Run("noop when no alert exists", func(t *testing.T) { t.Parallel() m := NewManager() storage := models.Storage{ ID: "storage-2", Name: "local-zfs", Node: "pve1", } var callbackCalled bool m.SetResolvedCallback(func(id string) { callbackCalled = true }) m.clearStorageOfflineAlert(storage) if callbackCalled { t.Error("expected no callback when no alert exists") } }) t.Run("clears offline confirmation even when no alert", func(t *testing.T) { t.Parallel() m := NewManager() storage := models.Storage{ ID: "storage-3", Name: "ceph-pool", Node: "pve2", } // Set confirmation without alert m.mu.Lock() m.offlineConfirmations[storage.ID] = 2 m.mu.Unlock() m.clearStorageOfflineAlert(storage) m.mu.RLock() _, confirmExists := m.offlineConfirmations[storage.ID] m.mu.RUnlock() if confirmExists { t.Error("expected offline confirmation to be cleared") } }) } func TestClearHostMetricAlerts(t *testing.T) { t.Parallel() t.Run("clears specified metrics", func(t *testing.T) { t.Parallel() m := NewManager() hostID := "my-host" resourceID := fmt.Sprintf("host:%s", hostID) // Create alerts for cpu and memory m.mu.Lock() m.activeAlerts[fmt.Sprintf("%s-cpu", resourceID)] = &Alert{ID: fmt.Sprintf("%s-cpu", resourceID)} m.activeAlerts[fmt.Sprintf("%s-memory", resourceID)] = &Alert{ID: fmt.Sprintf("%s-memory", resourceID)} m.activeAlerts[fmt.Sprintf("%s-disk", resourceID)] = &Alert{ID: fmt.Sprintf("%s-disk", resourceID)} m.mu.Unlock() m.clearHostMetricAlerts(hostID, "cpu", "disk") m.mu.RLock() _, cpuExists := m.activeAlerts[fmt.Sprintf("%s-cpu", resourceID)] _, memExists := m.activeAlerts[fmt.Sprintf("%s-memory", resourceID)] _, diskExists := m.activeAlerts[fmt.Sprintf("%s-disk", resourceID)] m.mu.RUnlock() if cpuExists { t.Error("expected cpu alert to be cleared") } if !memExists { t.Error("expected memory alert to remain (not specified)") } if diskExists { t.Error("expected disk alert to be cleared") } }) t.Run("defaults to cpu and memory when no metrics specified", func(t *testing.T) { t.Parallel() m := NewManager() hostID := "default-host" resourceID := fmt.Sprintf("host:%s", hostID) // Create alerts m.mu.Lock() m.activeAlerts[fmt.Sprintf("%s-cpu", resourceID)] = &Alert{ID: fmt.Sprintf("%s-cpu", resourceID)} m.activeAlerts[fmt.Sprintf("%s-memory", resourceID)] = &Alert{ID: fmt.Sprintf("%s-memory", resourceID)} m.activeAlerts[fmt.Sprintf("%s-disk", resourceID)] = &Alert{ID: fmt.Sprintf("%s-disk", resourceID)} m.mu.Unlock() m.clearHostMetricAlerts(hostID) // No metrics specified m.mu.RLock() _, cpuExists := m.activeAlerts[fmt.Sprintf("%s-cpu", resourceID)] _, memExists := m.activeAlerts[fmt.Sprintf("%s-memory", resourceID)] _, diskExists := m.activeAlerts[fmt.Sprintf("%s-disk", resourceID)] m.mu.RUnlock() if cpuExists { t.Error("expected cpu alert to be cleared (default)") } if memExists { t.Error("expected memory alert to be cleared (default)") } if !diskExists { t.Error("expected disk alert to remain (not in defaults)") } }) t.Run("empty hostID is noop", func(t *testing.T) { t.Parallel() m := NewManager() // Create an alert that should not be touched m.mu.Lock() m.activeAlerts["host:unknown-cpu"] = &Alert{ID: "host:unknown-cpu"} m.mu.Unlock() m.clearHostMetricAlerts("", "cpu") m.mu.RLock() _, exists := m.activeAlerts["host:unknown-cpu"] m.mu.RUnlock() if !exists { t.Error("expected alert to remain when hostID is empty") } }) } func TestClearHostDiskAlerts(t *testing.T) { t.Parallel() t.Run("clears all disk alerts for host", func(t *testing.T) { t.Parallel() m := NewManager() hostID := "disk-host" resourceID := fmt.Sprintf("host:%s", hostID) // Create disk alerts with the expected ResourceID format m.mu.Lock() m.activeAlerts["disk1-alert"] = &Alert{ ID: "disk1-alert", ResourceID: fmt.Sprintf("%s/disk:sda", resourceID), } m.activeAlerts["disk2-alert"] = &Alert{ ID: "disk2-alert", ResourceID: fmt.Sprintf("%s/disk:sdb", resourceID), } m.activeAlerts["cpu-alert"] = &Alert{ ID: "cpu-alert", ResourceID: fmt.Sprintf("%s-cpu", resourceID), } m.mu.Unlock() m.clearHostDiskAlerts(hostID) m.mu.RLock() _, disk1Exists := m.activeAlerts["disk1-alert"] _, disk2Exists := m.activeAlerts["disk2-alert"] _, cpuExists := m.activeAlerts["cpu-alert"] m.mu.RUnlock() if disk1Exists { t.Error("expected disk1 alert to be cleared") } if disk2Exists { t.Error("expected disk2 alert to be cleared") } if !cpuExists { t.Error("expected cpu alert to remain (not a disk alert)") } }) t.Run("empty hostID is noop", func(t *testing.T) { t.Parallel() m := NewManager() // Create an alert m.mu.Lock() m.activeAlerts["disk-alert"] = &Alert{ ID: "disk-alert", ResourceID: "host:unknown/disk:sda", } m.mu.Unlock() m.clearHostDiskAlerts("") m.mu.RLock() _, exists := m.activeAlerts["disk-alert"] m.mu.RUnlock() if !exists { t.Error("expected alert to remain when hostID is empty") } }) t.Run("skips nil alerts", func(t *testing.T) { t.Parallel() m := NewManager() hostID := "nil-test" resourceID := fmt.Sprintf("host:%s", hostID) m.mu.Lock() m.activeAlerts["nil-alert"] = nil m.activeAlerts["real-alert"] = &Alert{ ID: "real-alert", ResourceID: fmt.Sprintf("%s/disk:sda", resourceID), } m.mu.Unlock() // Should not panic m.clearHostDiskAlerts(hostID) m.mu.RLock() _, realExists := m.activeAlerts["real-alert"] m.mu.RUnlock() if realExists { t.Error("expected real alert to be cleared") } }) t.Run("noop when no matching alerts", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.activeAlerts["other-alert"] = &Alert{ ID: "other-alert", ResourceID: "host:other-host/disk:sda", } m.mu.Unlock() m.clearHostDiskAlerts("my-host") m.mu.RLock() _, exists := m.activeAlerts["other-alert"] m.mu.RUnlock() if !exists { t.Error("expected other host's alert to remain") } }) } func TestCleanupHostDiskAlerts(t *testing.T) { t.Parallel() t.Run("clears alerts not in seen set", func(t *testing.T) { t.Parallel() m := NewManager() host := models.Host{ID: "host-1"} resourceID := fmt.Sprintf("host:%s", host.ID) // Create disk alerts m.mu.Lock() m.activeAlerts["disk-sda"] = &Alert{ ID: "disk-sda", ResourceID: fmt.Sprintf("%s/disk:sda", resourceID), } m.activeAlerts["disk-sdb"] = &Alert{ ID: "disk-sdb", ResourceID: fmt.Sprintf("%s/disk:sdb", resourceID), } m.activeAlerts["disk-sdc"] = &Alert{ ID: "disk-sdc", ResourceID: fmt.Sprintf("%s/disk:sdc", resourceID), } m.mu.Unlock() // Only sda and sdb are in the seen set seen := map[string]struct{}{ fmt.Sprintf("%s/disk:sda", resourceID): {}, fmt.Sprintf("%s/disk:sdb", resourceID): {}, } m.cleanupHostDiskAlerts(host, seen) m.mu.RLock() _, sdaExists := m.activeAlerts["disk-sda"] _, sdbExists := m.activeAlerts["disk-sdb"] _, sdcExists := m.activeAlerts["disk-sdc"] m.mu.RUnlock() if !sdaExists { t.Error("expected sda alert to remain (in seen set)") } if !sdbExists { t.Error("expected sdb alert to remain (in seen set)") } if sdcExists { t.Error("expected sdc alert to be cleared (not in seen set)") } }) t.Run("empty host ID is noop", func(t *testing.T) { t.Parallel() m := NewManager() m.mu.Lock() m.activeAlerts["disk-alert"] = &Alert{ ID: "disk-alert", ResourceID: "host:unknown/disk:sda", } m.mu.Unlock() host := models.Host{ID: ""} m.cleanupHostDiskAlerts(host, nil) m.mu.RLock() _, exists := m.activeAlerts["disk-alert"] m.mu.RUnlock() if !exists { t.Error("expected alert to remain when host ID is empty") } }) t.Run("skips nil alerts", func(t *testing.T) { t.Parallel() m := NewManager() host := models.Host{ID: "host-2"} resourceID := fmt.Sprintf("host:%s", host.ID) m.mu.Lock() m.activeAlerts["nil-alert"] = nil m.activeAlerts["real-alert"] = &Alert{ ID: "real-alert", ResourceID: fmt.Sprintf("%s/disk:sda", resourceID), } m.mu.Unlock() seen := map[string]struct{}{} // Empty seen set // Should not panic m.cleanupHostDiskAlerts(host, seen) m.mu.RLock() _, realExists := m.activeAlerts["real-alert"] m.mu.RUnlock() if realExists { t.Error("expected real alert to be cleared (not in seen set)") } }) t.Run("skips non-matching prefix", func(t *testing.T) { t.Parallel() m := NewManager() host := models.Host{ID: "host-3"} resourceID := fmt.Sprintf("host:%s", host.ID) m.mu.Lock() m.activeAlerts["cpu-alert"] = &Alert{ ID: "cpu-alert", ResourceID: fmt.Sprintf("%s-cpu", resourceID), // Not a disk alert } m.activeAlerts["disk-alert"] = &Alert{ ID: "disk-alert", ResourceID: fmt.Sprintf("%s/disk:sda", resourceID), } m.mu.Unlock() seen := map[string]struct{}{} // Empty seen set m.cleanupHostDiskAlerts(host, seen) m.mu.RLock() _, cpuExists := m.activeAlerts["cpu-alert"] _, diskExists := m.activeAlerts["disk-alert"] m.mu.RUnlock() if !cpuExists { t.Error("expected cpu alert to remain (not a disk alert)") } if diskExists { t.Error("expected disk alert to be cleared") } }) } func TestHandleDockerHostRemovedEmptyID(t *testing.T) { t.Parallel() m := NewManager() // Create some alerts that should not be touched m.mu.Lock() m.activeAlerts["docker-host-offline-host1"] = &Alert{ID: "docker-host-offline-host1"} m.dockerOfflineCount["host1"] = 3 m.mu.Unlock() // Call with empty ID - should be noop host := models.DockerHost{ID: ""} m.HandleDockerHostRemoved(host) m.mu.RLock() _, alertExists := m.activeAlerts["docker-host-offline-host1"] _, countExists := m.dockerOfflineCount["host1"] m.mu.RUnlock() if !alertExists { t.Error("expected alert to remain when host ID is empty") } if !countExists { t.Error("expected offline count to remain when host ID is empty") } }