Refactor alert system: fix race conditions, memory leaks, and improve code quality

- Rename checkFlapping to checkFlappingLocked to clarify lock contract
- Replace goto statements with structured control flow
- Wire up unused recordAlertFired/recordAlertResolved metric hooks
- Add trackingMapCleanup goroutine to prevent memory leaks from stale entries
- Tighten alert ID validation to alphanumeric + safe punctuation
- Fix history save error handling to properly manage backup lifecycle
- Add auto-migration for deprecated GroupingWindow field
- Refactor 300+ line UpdateConfig into focused helper functions
- Unify duplicate evaluateVMCondition/evaluateContainerCondition
- Add constants for magic numbers (thresholds, timing, flapping)
- Update tests to match new backup behavior
This commit is contained in:
rcourtman 2025-12-02 23:31:36 +00:00
parent bff2a1f83e
commit 8fca8c6eb4
6 changed files with 617 additions and 311 deletions

View file

@ -34,6 +34,43 @@ const (
ActivationSnoozed ActivationState = "snoozed"
)
// Default thresholds and configuration values
const (
// Default threshold values
DefaultCPUTrigger = 80.0
DefaultCPUClear = 75.0
DefaultMemoryTrigger = 85.0
DefaultMemoryClear = 80.0
DefaultDiskTrigger = 90.0
DefaultDiskClear = 85.0
DefaultStorageTrigger = 85.0
DefaultStorageClear = 80.0
DefaultTempTrigger = 80.0
DefaultTempClear = 75.0
// Time thresholds
DefaultDelaySeconds = 5
DefaultSuppressionWindow = 5 // minutes
// Alert management
DefaultMinimumDelta = 2.0 // minimum % change to trigger new alert
DefaultHysteresisMargin = 5.0 // % margin between trigger and clear
DefaultObservationWindow = 24 // hours
// Flapping detection
DefaultFlappingWindow = 300 // seconds (5 minutes)
DefaultFlappingThreshold = 5 // state changes to trigger flapping
DefaultFlappingCooldown = 15 // minutes
// Confirmation counts for transient state detection
RequiredOfflineConfirmations = 3
RequiredStateConfirmations = 2
// Cleanup intervals
StaleTrackingThreshold = 24 * time.Hour
RateLimitCleanupWindow = 1 * time.Hour
)
func normalizePoweredOffSeverity(level AlertLevel) AlertLevel {
switch strings.ToLower(string(level)) {
case string(AlertLevelCritical):
@ -220,7 +257,7 @@ type GroupingConfig struct {
type ScheduleConfig struct {
QuietHours QuietHours `json:"quietHours"`
Cooldown int `json:"cooldown"` // minutes
GroupingWindow int `json:"groupingWindow"` // seconds (deprecated, use Grouping.Window)
GroupingWindow int `json:"groupingWindow,omitempty"` // Deprecated: use Grouping.Window instead. Will be auto-migrated on config update.
MaxAlertsHour int `json:"maxAlertsHour"` // max alerts per hour per resource
NotifyOnResolve bool `json:"notifyOnResolve"` // Send notification when alert clears
Escalation EscalationConfig `json:"escalation"`
@ -432,14 +469,15 @@ var (
)
// SetMetricHooks registers callbacks for recording alert metrics.
// Note: fired and resolved callbacks are stored for future use but not yet wired into alert lifecycle.
// - fired: called when an alert is dispatched (in dispatchAlert)
// - resolved: called when an alert is cleared (in clearAlertNoLock)
// - suppressed: called when an alert is suppressed due to flapping
// - acknowledged: called when an alert is acknowledged
func SetMetricHooks(fired func(*Alert), resolved func(*Alert), suppressed func(string), acknowledged func()) {
recordAlertFired = fired
recordAlertResolved = resolved
recordAlertSuppressed = suppressed
recordAlertAcknowledged = acknowledged
// Silence staticcheck U1000 - hooks are stored for future metric integration
_, _ = recordAlertFired, recordAlertResolved
}
type Manager struct {
@ -476,6 +514,8 @@ type Manager struct {
// Flapping detection tracking
flappingHistory map[string][]time.Time // Track state change times for flapping detection
flappingActive map[string]bool // Track which alerts are currently in flapping state
// Cleanup control
cleanupStop chan struct{} // Signal to stop cleanup goroutine
}
type ackRecord struct {
@ -514,6 +554,7 @@ func NewManager() *Manager {
ackState: make(map[string]ackRecord),
flappingHistory: make(map[string][]time.Time),
flappingActive: make(map[string]bool),
cleanupStop: make(chan struct{}),
config: AlertConfig{
Enabled: true,
ActivationState: ActivationPending,
@ -608,9 +649,9 @@ func NewManager() *Manager {
},
Suppress: QuietHoursSuppression{},
},
Cooldown: 5, // ON - 5 minutes prevents spam
GroupingWindow: 30, // ON - 30 seconds groups related alerts
MaxAlertsHour: 10, // ON - 10 alerts/hour prevents flooding
Cooldown: 5, // ON - 5 minutes prevents spam
MaxAlertsHour: 10, // ON - 10 alerts/hour prevents flooding
// Note: GroupingWindow is deprecated - use Grouping.Window instead
NotifyOnResolve: true,
Escalation: EscalationConfig{
Enabled: false, // OFF - requires user configuration
@ -650,6 +691,9 @@ func NewManager() *Manager {
// Start periodic save of active alerts
go m.periodicSaveAlerts()
// Start periodic cleanup of stale tracking map entries
go m.trackingMapCleanup()
return m
}
@ -736,12 +780,10 @@ func (m *Manager) safeCallEscalateCallback(alert *Alert, level int) {
}(alertCopy, level)
}
// dispatchAlert delivers an alert to the configured callback, cloning it first to
// prevent concurrent mutations from racing with consumers.
// checkFlapping checks if an alert is flapping (changing state too rapidly)
// checkFlapping detects alert flapping and returns true if alert should be suppressed.
// IMPORTANT: Caller MUST hold m.mu (writes to flappingHistory, flappingActive, suppressedUntil maps)
func (m *Manager) checkFlapping(alertID string) bool {
// checkFlappingLocked detects alert flapping and returns true if alert should be suppressed.
// It modifies flappingHistory, flappingActive, and suppressedUntil maps.
// IMPORTANT: Caller MUST hold m.mu before calling this function.
func (m *Manager) checkFlappingLocked(alertID string) bool {
if !m.config.FlappingEnabled {
return false
}
@ -795,8 +837,8 @@ func (m *Manager) dispatchAlert(alert *Alert, async bool) bool {
return false
}
// Check for flapping
if m.checkFlapping(alert.ID) {
// Check for flapping (caller must hold m.mu)
if m.checkFlappingLocked(alert.ID) {
log.Debug().
Str("alertID", alert.ID).
Msg("Alert suppressed due to flapping")
@ -831,6 +873,11 @@ func (m *Manager) dispatchAlert(alert *Alert, async bool) bool {
return false
}
// Record metric for fired alert
if recordAlertFired != nil {
recordAlertFired(alert)
}
alertCopy := alert.Clone()
if async {
go func(a *Alert) {
@ -903,43 +950,93 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
m.mu.Lock()
defer m.mu.Unlock()
// Preserve defaults for zero values
// Normalize all config sections
normalizeStorageDefaults(&config)
normalizeDockerDefaults(&config)
normalizePMGDefaults(&config)
normalizeSnapshotDefaults(&config)
normalizeBackupDefaults(&config)
normalizeNodeDefaults(&config)
normalizeHostDefaults(&config)
normalizeGeneralSettings(&config)
normalizeTimeThresholds(&config)
config.GuestDefaults.PoweredOffSeverity = normalizePoweredOffSeverity(config.GuestDefaults.PoweredOffSeverity)
config.NodeDefaults.PoweredOffSeverity = normalizePoweredOffSeverity(config.NodeDefaults.PoweredOffSeverity)
config.DockerIgnoredContainerPrefixes = NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
// Migration logic for activation state (backward compatibility)
m.migrateActivationState(&config)
// Validate hysteresis thresholds to prevent stuck alerts
validateHysteresisThresholds(&config)
// Validate timezone if quiet hours are enabled
validateQuietHoursTimezone(&config)
m.config = config
normalizeOverrides(m.config.Overrides)
if !m.config.SnapshotDefaults.Enabled {
m.clearSnapshotAlertsForInstanceLocked("")
}
if !m.config.BackupDefaults.Enabled {
m.clearBackupAlertsLocked()
}
m.applyGlobalOfflineSettingsLocked()
log.Info().
Bool("enabled", config.Enabled).
Interface("guestDefaults", config.GuestDefaults).
Msg("Alert configuration updated")
// Re-evaluate active alerts against new thresholds
m.reevaluateActiveAlertsLocked()
}
// normalizeStorageDefaults ensures storage default thresholds are set
func normalizeStorageDefaults(config *AlertConfig) {
if config.StorageDefault.Trigger <= 0 {
config.StorageDefault.Trigger = 85
config.StorageDefault.Clear = 80
}
}
// Initialize Docker defaults while allowing explicit disables (trigger == 0)
normalizeDockerThreshold := func(th HysteresisThreshold, defaultTrigger float64, metricName string) HysteresisThreshold {
normalized := th
// normalizeDockerThreshold normalizes a single Docker threshold
func normalizeDockerThreshold(th HysteresisThreshold, defaultTrigger float64, metricName string) HysteresisThreshold {
normalized := th
// Negative triggers are treated as unset and replaced with defaults.
if normalized.Trigger < 0 {
normalized.Trigger = defaultTrigger
// Negative triggers are treated as unset and replaced with defaults.
if normalized.Trigger < 0 {
normalized.Trigger = defaultTrigger
}
// Explicit disable: keep trigger at 0 and clamp clear to 0.
if normalized.Trigger == 0 {
if normalized.Clear < 0 {
normalized.Clear = 0
}
// Explicit disable: keep trigger at 0 and clamp clear to 0.
if normalized.Trigger == 0 {
if normalized.Clear < 0 {
normalized.Clear = 0
}
return normalized
}
if normalized.Clear <= 0 {
normalized.Clear = normalized.Trigger - 5
if normalized.Clear < 0 {
normalized.Clear = 0
}
}
ensureValidHysteresis(&normalized, metricName)
return normalized
}
if normalized.Clear <= 0 {
normalized.Clear = normalized.Trigger - 5
if normalized.Clear < 0 {
normalized.Clear = 0
}
}
ensureValidHysteresis(&normalized, metricName)
return normalized
}
// normalizeDockerDefaults ensures Docker default thresholds are set
func normalizeDockerDefaults(config *AlertConfig) {
config.DockerDefaults.CPU = normalizeDockerThreshold(config.DockerDefaults.CPU, 80, "docker.cpu")
config.DockerDefaults.Memory = normalizeDockerThreshold(config.DockerDefaults.Memory, 85, "docker.memory")
config.DockerDefaults.Disk = normalizeDockerThreshold(config.DockerDefaults.Disk, 85, "docker.disk")
if config.DockerDefaults.RestartCount <= 0 {
config.DockerDefaults.RestartCount = 3
}
@ -970,8 +1067,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
config.DockerDefaults.StatePoweredOffSeverity = AlertLevelWarning
}
config.DockerDefaults.StatePoweredOffSeverity = normalizePoweredOffSeverity(config.DockerDefaults.StatePoweredOffSeverity)
}
// Initialize PMG defaults if missing/zero
// normalizePMGDefaults ensures PMG (Proxmox Mail Gateway) defaults are set
func normalizePMGDefaults(config *AlertConfig) {
if config.PMGDefaults.QueueTotalWarning <= 0 {
config.PMGDefaults.QueueTotalWarning = 500
}
@ -1020,7 +1119,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if config.PMGDefaults.QuarantineGrowthCritMin <= 0 {
config.PMGDefaults.QuarantineGrowthCritMin = 500
}
}
// normalizeSnapshotDefaults ensures snapshot alert thresholds are valid
func normalizeSnapshotDefaults(config *AlertConfig) {
if config.SnapshotDefaults.WarningDays < 0 {
config.SnapshotDefaults.WarningDays = 0
}
@ -1045,6 +1147,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if config.SnapshotDefaults.CriticalSizeGiB == 0 && config.SnapshotDefaults.WarningSizeGiB > 0 {
config.SnapshotDefaults.CriticalSizeGiB = config.SnapshotDefaults.WarningSizeGiB
}
}
// normalizeBackupDefaults ensures backup alert thresholds are valid
func normalizeBackupDefaults(config *AlertConfig) {
if config.BackupDefaults.WarningDays < 0 {
config.BackupDefaults.WarningDays = 0
}
@ -1054,18 +1160,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if config.BackupDefaults.CriticalDays > 0 && config.BackupDefaults.WarningDays > config.BackupDefaults.CriticalDays {
config.BackupDefaults.WarningDays = config.BackupDefaults.CriticalDays
}
}
// Ensure minimums for other important fields
if config.MinimumDelta <= 0 {
config.MinimumDelta = 2.0
}
if config.SuppressionWindow <= 0 {
config.SuppressionWindow = 5
}
if config.HysteresisMargin <= 0 {
config.HysteresisMargin = 5.0
}
// normalizeNodeDefaults ensures node threshold defaults exist
func normalizeNodeDefaults(config *AlertConfig) {
// Ensure temperature defaults exist for nodes so high temps alert out of the box
if config.NodeDefaults.Temperature == nil || config.NodeDefaults.Temperature.Trigger <= 0 {
config.NodeDefaults.Temperature = &HysteresisThreshold{Trigger: 80, Clear: 75}
@ -1075,8 +1173,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
config.NodeDefaults.Temperature.Clear = 75
}
}
}
// Ensure host agent defaults exist
// normalizeHostDefaults ensures host agent threshold defaults exist
func normalizeHostDefaults(config *AlertConfig) {
if config.HostDefaults.CPU == nil || config.HostDefaults.CPU.Trigger <= 0 {
config.HostDefaults.CPU = &HysteresisThreshold{Trigger: 80, Clear: 75}
} else if config.HostDefaults.CPU.Clear <= 0 {
@ -1101,8 +1201,26 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
config.HostDefaults.Disk.Clear = 85
}
}
}
// Normalize any metric-level delay overrides
// normalizeGeneralSettings ensures general alert settings have valid values
func normalizeGeneralSettings(config *AlertConfig) {
if config.MinimumDelta <= 0 {
config.MinimumDelta = 2.0
}
if config.SuppressionWindow <= 0 {
config.SuppressionWindow = 5
}
if config.HysteresisMargin <= 0 {
config.HysteresisMargin = 5.0
}
if config.ObservationWindowHours <= 0 {
config.ObservationWindowHours = 24
}
}
// normalizeTimeThresholds ensures time threshold settings are valid
func normalizeTimeThresholds(config *AlertConfig) {
config.MetricTimeThresholds = normalizeMetricTimeThresholds(config.MetricTimeThresholds)
const defaultDelaySeconds = 5
@ -1125,15 +1243,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
if delay, ok := config.TimeThresholds["all"]; ok && delay < 0 {
config.TimeThresholds["all"] = defaultDelaySeconds
}
config.DockerIgnoredContainerPrefixes = NormalizeDockerIgnoredPrefixes(config.DockerIgnoredContainerPrefixes)
}
config.GuestDefaults.PoweredOffSeverity = normalizePoweredOffSeverity(config.GuestDefaults.PoweredOffSeverity)
config.NodeDefaults.PoweredOffSeverity = normalizePoweredOffSeverity(config.NodeDefaults.PoweredOffSeverity)
// Migration logic for activation state (backward compatibility)
if config.ObservationWindowHours <= 0 {
config.ObservationWindowHours = 24
}
// migrateActivationState handles backward compatibility for activation state
func (m *Manager) migrateActivationState(config *AlertConfig) {
if config.ActivationState == "" {
// Determine if this is an existing installation or new
// Existing installations have active alerts already
@ -1150,8 +1263,10 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
log.Info().Msg("New installation: alerts pending activation")
}
}
}
// Validate hysteresis thresholds to prevent stuck alerts
// validateHysteresisThresholds ensures hysteresis thresholds won't cause stuck alerts
func validateHysteresisThresholds(config *AlertConfig) {
ensureValidHysteresis(config.GuestDefaults.CPU, "guest.cpu")
ensureValidHysteresis(config.GuestDefaults.Memory, "guest.memory")
ensureValidHysteresis(config.GuestDefaults.Disk, "guest.disk")
@ -1159,48 +1274,32 @@ func (m *Manager) UpdateConfig(config AlertConfig) {
ensureValidHysteresis(config.NodeDefaults.Memory, "node.memory")
ensureValidHysteresis(config.NodeDefaults.Temperature, "node.temperature")
ensureValidHysteresis(&config.StorageDefault, "storage")
}
// Validate timezone if quiet hours are enabled
if config.Schedule.QuietHours.Enabled {
if config.Schedule.QuietHours.Timezone != "" {
_, err := time.LoadLocation(config.Schedule.QuietHours.Timezone)
if err != nil {
log.Error().
Err(err).
Str("timezone", config.Schedule.QuietHours.Timezone).
Msg("Invalid timezone in quiet hours config, disabling quiet hours")
// Disable quiet hours rather than silently using wrong timezone
config.Schedule.QuietHours.Enabled = false
}
// validateQuietHoursTimezone validates the timezone for quiet hours
func validateQuietHoursTimezone(config *AlertConfig) {
if config.Schedule.QuietHours.Enabled && config.Schedule.QuietHours.Timezone != "" {
_, err := time.LoadLocation(config.Schedule.QuietHours.Timezone)
if err != nil {
log.Error().
Err(err).
Str("timezone", config.Schedule.QuietHours.Timezone).
Msg("Invalid timezone in quiet hours config, disabling quiet hours")
// Disable quiet hours rather than silently using wrong timezone
config.Schedule.QuietHours.Enabled = false
}
}
}
m.config = config
for id, override := range m.config.Overrides {
// normalizeOverrides normalizes all threshold overrides
func normalizeOverrides(overrides map[string]ThresholdConfig) {
for id, override := range overrides {
override.PoweredOffSeverity = normalizePoweredOffSeverity(override.PoweredOffSeverity)
if override.Usage != nil {
override.Usage = ensureHysteresisThreshold(override.Usage)
m.config.Overrides[id] = override
}
m.config.Overrides[id] = override
overrides[id] = override
}
if !m.config.SnapshotDefaults.Enabled {
m.clearSnapshotAlertsForInstanceLocked("")
}
if !m.config.BackupDefaults.Enabled {
m.clearBackupAlertsLocked()
}
m.applyGlobalOfflineSettingsLocked()
log.Info().
Bool("enabled", config.Enabled).
Interface("guestDefaults", config.GuestDefaults).
Msg("Alert configuration updated")
// Re-evaluate active alerts against new thresholds
m.reevaluateActiveAlertsLocked()
}
// normalizeMetricTimeThresholds cleans resource/metric keys and drops invalid delay overrides.
@ -8222,6 +8321,179 @@ func (m *Manager) periodicSaveAlerts() {
}
}
// trackingMapCleanup periodically cleans up stale entries from tracking maps
// to prevent unbounded memory growth from deleted/decommissioned resources.
func (m *Manager) trackingMapCleanup() {
// Run cleanup every hour
ticker := time.NewTicker(1 * time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
m.cleanupStaleMaps()
case <-m.cleanupStop:
return
}
}
}
// cleanupStaleMaps removes stale entries from tracking maps.
// Entries are considered stale if they haven't been updated in 24 hours
// and don't correspond to any active alert.
func (m *Manager) cleanupStaleMaps() {
m.mu.Lock()
defer m.mu.Unlock()
now := time.Now()
staleThreshold := StaleTrackingThreshold
cleaned := 0
// Clean up flapping history for resources without active alerts
for alertID, history := range m.flappingHistory {
if _, hasAlert := m.activeAlerts[alertID]; !hasAlert {
// Check if history is stale (last entry older than threshold)
if len(history) == 0 || now.Sub(history[len(history)-1]) > staleThreshold {
delete(m.flappingHistory, alertID)
delete(m.flappingActive, alertID)
cleaned++
}
}
}
// Clean up suppressedUntil entries that have expired
for alertID, suppressUntil := range m.suppressedUntil {
if now.After(suppressUntil) {
delete(m.suppressedUntil, alertID)
cleaned++
}
}
// Clean up pending alerts older than threshold without active alerts
for alertID, pendingTime := range m.pendingAlerts {
if _, hasAlert := m.activeAlerts[alertID]; !hasAlert {
if now.Sub(pendingTime) > staleThreshold {
delete(m.pendingAlerts, alertID)
cleaned++
}
}
}
// Clean up offline confirmation counts for resources without active alerts
for resourceID := range m.offlineConfirmations {
hasRelatedAlert := false
for alertID := range m.activeAlerts {
if strings.Contains(alertID, resourceID) {
hasRelatedAlert = true
break
}
}
if !hasRelatedAlert {
delete(m.offlineConfirmations, resourceID)
cleaned++
}
}
// Clean up node offline counts (legacy)
for nodeID := range m.nodeOfflineCount {
hasRelatedAlert := false
for alertID := range m.activeAlerts {
if strings.Contains(alertID, nodeID) {
hasRelatedAlert = true
break
}
}
if !hasRelatedAlert {
delete(m.nodeOfflineCount, nodeID)
cleaned++
}
}
// Clean up Docker tracking maps
for containerID := range m.dockerStateConfirm {
hasRelatedAlert := false
for alertID := range m.activeAlerts {
if strings.Contains(alertID, containerID) {
hasRelatedAlert = true
break
}
}
if !hasRelatedAlert {
delete(m.dockerStateConfirm, containerID)
cleaned++
}
}
for hostID := range m.dockerOfflineCount {
hasRelatedAlert := false
for alertID := range m.activeAlerts {
if strings.Contains(alertID, hostID) {
hasRelatedAlert = true
break
}
}
if !hasRelatedAlert {
delete(m.dockerOfflineCount, hostID)
cleaned++
}
}
// Clean up Docker restart tracking for stale containers
for containerID, record := range m.dockerRestartTracking {
if record != nil && now.Sub(record.lastChecked) > staleThreshold {
delete(m.dockerRestartTracking, containerID)
delete(m.dockerLastExitCode, containerID)
cleaned++
}
}
// Clean up rate limit entries older than 1 hour
rateLimitThreshold := RateLimitCleanupWindow
for resourceID, times := range m.alertRateLimit {
// Filter to keep only recent entries
var recent []time.Time
for _, t := range times {
if now.Sub(t) < rateLimitThreshold {
recent = append(recent, t)
}
}
if len(recent) == 0 {
delete(m.alertRateLimit, resourceID)
cleaned++
} else if len(recent) < len(times) {
m.alertRateLimit[resourceID] = recent
}
}
// Clean up recent alerts older than suppression window
suppressWindow := time.Duration(m.config.SuppressionWindow) * time.Minute
if suppressWindow <= 0 {
suppressWindow = 5 * time.Minute
}
for alertID, alert := range m.recentAlerts {
if now.Sub(alert.LastSeen) > suppressWindow {
delete(m.recentAlerts, alertID)
cleaned++
}
}
// Clean up ackState for alerts that no longer exist and are older than threshold
for alertID, record := range m.ackState {
if _, hasAlert := m.activeAlerts[alertID]; !hasAlert {
if now.Sub(record.time) > staleThreshold {
delete(m.ackState, alertID)
cleaned++
}
}
}
if cleaned > 0 {
log.Debug().
Int("entriesCleaned", cleaned).
Msg("Cleaned stale entries from alert tracking maps")
}
}
// hasKnownFirmwareBug checks if a disk model is known to have firmware bugs that cause
// false health status reports. These drives may report FAILED or other error states
// due to firmware issues (e.g., incorrect temperature thresholds) even when the drive
@ -8260,24 +8532,25 @@ func (m *Manager) CheckDiskHealth(instance, node string, disk proxmox.Disk) {
// Check if disk health is not PASSED
normalizedHealth := strings.ToUpper(strings.TrimSpace(disk.Health))
if normalizedHealth != "" && normalizedHealth != "UNKNOWN" && normalizedHealth != "PASSED" && normalizedHealth != "OK" {
// Skip health alerts for drives with known firmware bugs that cause false reports
// These drives may report FAILED status due to firmware issues even when healthy
// We still monitor wearout below, which is more reliable for these drives
if hasKnownFirmwareBug(disk.Model) {
log.Debug().
Str("node", node).
Str("disk", disk.DevPath).
Str("model", disk.Model).
Str("health", disk.Health).
Msg("Skipping health alert for drive with known firmware bug - health status unreliable")
healthCheckNeeded := normalizedHealth != "" && normalizedHealth != "UNKNOWN" && normalizedHealth != "PASSED" && normalizedHealth != "OK"
// Clear any existing health alert since we now recognize this is a false positive
m.clearAlertNoLock(alertID)
// Skip health alerts for drives with known firmware bugs that cause false reports
// These drives may report FAILED status due to firmware issues even when healthy
// We still monitor wearout below, which is more reliable for these drives
if healthCheckNeeded && hasKnownFirmwareBug(disk.Model) {
log.Debug().
Str("node", node).
Str("disk", disk.DevPath).
Str("model", disk.Model).
Str("health", disk.Health).
Msg("Skipping health alert for drive with known firmware bug - health status unreliable")
// Continue to wearout check below - wearout monitoring is still valid
goto checkWearout
}
// Clear any existing health alert since we now recognize this is a false positive
m.clearAlertNoLock(alertID)
healthCheckNeeded = false // Skip to wearout check
}
if healthCheckNeeded {
// Check if alert already exists
if _, exists := m.activeAlerts[alertID]; !exists {
// Create new health alert
@ -8324,7 +8597,6 @@ func (m *Manager) CheckDiskHealth(instance, node string, disk proxmox.Disk) {
m.clearAlertNoLock(alertID)
}
checkWearout:
// Check for low wearout (SSD life remaining)
if disk.Wearout > 0 && disk.Wearout < 10 {
wearoutAlertID := fmt.Sprintf("disk-wearout-%s-%s-%s", instance, node, disk.DevPath)
@ -8403,6 +8675,11 @@ func (m *Manager) clearAlertNoLock(alertID string) {
return
}
// Record metric for resolved alert
if recordAlertResolved != nil {
recordAlertResolved(alert)
}
m.removeActiveAlertNoLock(alertID)
resolvedAlert := &ResolvedAlert{
Alert: alert,

View file

@ -2384,13 +2384,13 @@ func TestCheckFlapping(t *testing.T) {
}
m.mu.Unlock()
// Call checkFlapping
// Call checkFlappingLocked
m.mu.Lock()
result := m.checkFlapping(alertID)
result := m.checkFlappingLocked(alertID)
m.mu.Unlock()
if result != tt.expectFlapping {
t.Errorf("checkFlapping() = %v, want %v", result, tt.expectFlapping)
t.Errorf("checkFlappingLocked() = %v, want %v", result, tt.expectFlapping)
}
// Check if flapping was newly detected
@ -2437,13 +2437,13 @@ func TestCheckFlappingAlreadyFlapping(t *testing.T) {
}
m.mu.Unlock()
// Call checkFlapping - should return true but NOT update suppression
// Call checkFlappingLocked - should return true but NOT update suppression
m.mu.Lock()
result := m.checkFlapping(alertID)
result := m.checkFlappingLocked(alertID)
m.mu.Unlock()
if !result {
t.Errorf("checkFlapping() = false, want true for already flapping alert")
t.Errorf("checkFlappingLocked() = false, want true for already flapping alert")
}
// Verify suppression time was NOT updated (existing suppression should remain)
@ -2479,14 +2479,14 @@ func TestCheckFlappingWindowExpiry(t *testing.T) {
}
m.mu.Unlock()
// Call checkFlapping - old entries should be pruned
// Call checkFlappingLocked - old entries should be pruned
m.mu.Lock()
result := m.checkFlapping(alertID)
result := m.checkFlappingLocked(alertID)
historyLen := len(m.flappingHistory[alertID])
m.mu.Unlock()
if result {
t.Errorf("checkFlapping() = true, want false (old entries should be pruned)")
t.Errorf("checkFlappingLocked() = true, want false (old entries should be pruned)")
}
// Only the current call should remain in history

View file

@ -20,148 +20,145 @@ func (m *Manager) evaluateFilterCondition(guest interface{}, condition FilterCon
}
}
// evaluateVMCondition evaluates a filter condition against a VM
func (m *Manager) evaluateVMCondition(vm models.VM, condition FilterCondition) bool {
// guestMetrics holds common metrics for filter evaluation
type guestMetrics struct {
CPU float64 // CPU usage as percentage (0-100)
MemUsage float64 // Memory usage percentage
DiskUsage float64 // Disk usage percentage
DiskRead int64 // Bytes/s
DiskWrite int64 // Bytes/s
NetworkIn int64 // Bytes/s
NetworkOut int64 // Bytes/s
Name string
Node string
ID string
Status string
}
// extractGuestMetrics extracts common metrics from a VM or Container
func extractGuestMetrics(guest interface{}) (guestMetrics, bool) {
switch g := guest.(type) {
case models.VM:
return guestMetrics{
CPU: g.CPU * 100,
MemUsage: g.Memory.Usage,
DiskUsage: g.Disk.Usage,
DiskRead: g.DiskRead,
DiskWrite: g.DiskWrite,
NetworkIn: g.NetworkIn,
NetworkOut: g.NetworkOut,
Name: g.Name,
Node: g.Node,
ID: g.ID,
Status: g.Status,
}, true
case models.Container:
return guestMetrics{
CPU: g.CPU * 100,
MemUsage: g.Memory.Usage,
DiskUsage: g.Disk.Usage,
DiskRead: g.DiskRead,
DiskWrite: g.DiskWrite,
NetworkIn: g.NetworkIn,
NetworkOut: g.NetworkOut,
Name: g.Name,
Node: g.Node,
ID: g.ID,
Status: g.Status,
}, true
default:
return guestMetrics{}, false
}
}
// evaluateGuestCondition evaluates a filter condition against guest metrics
func evaluateGuestCondition(metrics guestMetrics, condition FilterCondition) bool {
switch condition.Type {
case "metric":
value := 0.0
switch strings.ToLower(condition.Field) {
case "cpu":
value = vm.CPU * 100
value = metrics.CPU
case "memory":
value = vm.Memory.Usage
value = metrics.MemUsage
case "disk":
value = vm.Disk.Usage
value = metrics.DiskUsage
case "diskread":
value = float64(vm.DiskRead) / 1024 / 1024 // Convert to MB/s
value = float64(metrics.DiskRead) / 1024 / 1024 // Convert to MB/s
case "diskwrite":
value = float64(vm.DiskWrite) / 1024 / 1024
value = float64(metrics.DiskWrite) / 1024 / 1024
case "networkin":
value = float64(vm.NetworkIn) / 1024 / 1024
value = float64(metrics.NetworkIn) / 1024 / 1024
case "networkout":
value = float64(vm.NetworkOut) / 1024 / 1024
value = float64(metrics.NetworkOut) / 1024 / 1024
default:
return false
}
condValue, ok := condition.Value.(float64)
if !ok {
// Try to convert from int
if intVal, ok := condition.Value.(int); ok {
condValue = float64(intVal)
} else {
return false
}
}
switch condition.Operator {
case ">":
return value > condValue
case "<":
return value < condValue
case ">=":
return value >= condValue
case "<=":
return value <= condValue
case "=", "==":
return value >= condValue-0.5 && value <= condValue+0.5
}
return evaluateNumericCondition(value, condition)
case "text":
searchValue := strings.ToLower(fmt.Sprintf("%v", condition.Value))
switch strings.ToLower(condition.Field) {
case "name":
return strings.Contains(strings.ToLower(vm.Name), searchValue)
return strings.Contains(strings.ToLower(metrics.Name), searchValue)
case "node":
return strings.Contains(strings.ToLower(vm.Node), searchValue)
return strings.Contains(strings.ToLower(metrics.Node), searchValue)
case "vmid":
return strings.Contains(vm.ID, searchValue)
return strings.Contains(metrics.ID, searchValue)
}
case "raw":
if condition.RawText != "" {
term := strings.ToLower(condition.RawText)
return strings.Contains(strings.ToLower(vm.Name), term) ||
strings.Contains(vm.ID, term) ||
strings.Contains(strings.ToLower(vm.Node), term) ||
strings.Contains(strings.ToLower(vm.Status), term)
return strings.Contains(strings.ToLower(metrics.Name), term) ||
strings.Contains(metrics.ID, term) ||
strings.Contains(strings.ToLower(metrics.Node), term) ||
strings.Contains(strings.ToLower(metrics.Status), term)
}
}
return false
}
// evaluateContainerCondition evaluates a filter condition against a Container
func (m *Manager) evaluateContainerCondition(ct models.Container, condition FilterCondition) bool {
// Similar logic to evaluateVMCondition but for Container type
switch condition.Type {
case "metric":
value := 0.0
switch strings.ToLower(condition.Field) {
case "cpu":
value = ct.CPU * 100
case "memory":
value = ct.Memory.Usage
case "disk":
value = ct.Disk.Usage
case "diskread":
value = float64(ct.DiskRead) / 1024 / 1024
case "diskwrite":
value = float64(ct.DiskWrite) / 1024 / 1024
case "networkin":
value = float64(ct.NetworkIn) / 1024 / 1024
case "networkout":
value = float64(ct.NetworkOut) / 1024 / 1024
default:
// evaluateNumericCondition evaluates a numeric comparison
func evaluateNumericCondition(value float64, condition FilterCondition) bool {
condValue, ok := condition.Value.(float64)
if !ok {
// Try to convert from int
if intVal, ok := condition.Value.(int); ok {
condValue = float64(intVal)
} else {
return false
}
condValue, ok := condition.Value.(float64)
if !ok {
if intVal, ok := condition.Value.(int); ok {
condValue = float64(intVal)
} else {
return false
}
}
switch condition.Operator {
case ">":
return value > condValue
case "<":
return value < condValue
case ">=":
return value >= condValue
case "<=":
return value <= condValue
case "=", "==":
return value >= condValue-0.5 && value <= condValue+0.5
}
case "text":
searchValue := strings.ToLower(fmt.Sprintf("%v", condition.Value))
switch strings.ToLower(condition.Field) {
case "name":
return strings.Contains(strings.ToLower(ct.Name), searchValue)
case "node":
return strings.Contains(strings.ToLower(ct.Node), searchValue)
case "vmid":
return strings.Contains(ct.ID, searchValue)
}
case "raw":
if condition.RawText != "" {
term := strings.ToLower(condition.RawText)
return strings.Contains(strings.ToLower(ct.Name), term) ||
strings.Contains(ct.ID, term) ||
strings.Contains(strings.ToLower(ct.Node), term) ||
strings.Contains(strings.ToLower(ct.Status), term)
}
}
switch condition.Operator {
case ">":
return value > condValue
case "<":
return value < condValue
case ">=":
return value >= condValue
case "<=":
return value <= condValue
case "=", "==":
return value >= condValue-0.5 && value <= condValue+0.5
}
return false
}
// evaluateVMCondition evaluates a filter condition against a VM
func (m *Manager) evaluateVMCondition(vm models.VM, condition FilterCondition) bool {
metrics, _ := extractGuestMetrics(vm)
return evaluateGuestCondition(metrics, condition)
}
// evaluateContainerCondition evaluates a filter condition against a Container
func (m *Manager) evaluateContainerCondition(ct models.Container, condition FilterCondition) bool {
metrics, _ := extractGuestMetrics(ct)
return evaluateGuestCondition(metrics, condition)
}
// evaluateFilterStack evaluates a filter stack against a guest
func (m *Manager) evaluateFilterStack(guest interface{}, stack FilterStack) bool {
if len(stack.Filters) == 0 {
@ -270,66 +267,9 @@ func (m *Manager) getGuestThresholds(guest interface{}, guestID string) Threshol
// If not found, try legacy ID formats for migration
if !exists {
var legacyID string
var node string
var vmid int
var instance string
// Extract node, vmid, and instance from the guest object
switch g := guest.(type) {
case models.VM:
node = g.Node
vmid = g.VMID
instance = g.Instance
case models.Container:
node = g.Node
vmid = g.VMID
instance = g.Instance
default:
// Not a VM or container, skip legacy migration
goto skipLegacyMigration
}
// Try legacy format: instance-node-VMID
if instance != node {
legacyID = fmt.Sprintf("%s-%s-%d", instance, node, vmid)
if legacyOverride, legacyExists := m.config.Overrides[legacyID]; legacyExists {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest override from legacy ID format")
// Move to new ID
m.config.Overrides[guestID] = legacyOverride
delete(m.config.Overrides, legacyID)
// Config will be persisted on next save cycle
override = legacyOverride
exists = true
}
}
// If still not found, try standalone format: node-VMID
if !exists && instance == node {
legacyID = fmt.Sprintf("%s-%d", node, vmid)
if legacyOverride, legacyExists := m.config.Overrides[legacyID]; legacyExists {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest override from legacy standalone ID format")
// Move to new ID
m.config.Overrides[guestID] = legacyOverride
delete(m.config.Overrides, legacyID)
// Config will be persisted on next save cycle
override = legacyOverride
exists = true
}
}
override, exists = m.tryLegacyOverrideMigration(guest, guestID)
}
skipLegacyMigration:
if exists {
// Apply the disabled flag if set
if override.Disabled {
@ -378,3 +318,62 @@ skipLegacyMigration:
return thresholds
}
// tryLegacyOverrideMigration attempts to find and migrate legacy override formats.
// Returns the override and true if found, or zero value and false otherwise.
func (m *Manager) tryLegacyOverrideMigration(guest interface{}, guestID string) (ThresholdConfig, bool) {
var node string
var vmid int
var instance string
// Extract node, vmid, and instance from the guest object
switch g := guest.(type) {
case models.VM:
node = g.Node
vmid = g.VMID
instance = g.Instance
case models.Container:
node = g.Node
vmid = g.VMID
instance = g.Instance
default:
// Not a VM or container, no legacy migration possible
return ThresholdConfig{}, false
}
// Try legacy format: instance-node-VMID
if instance != node {
legacyID := fmt.Sprintf("%s-%s-%d", instance, node, vmid)
if legacyOverride, legacyExists := m.config.Overrides[legacyID]; legacyExists {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest override from legacy ID format")
// Move to new ID
m.config.Overrides[guestID] = legacyOverride
delete(m.config.Overrides, legacyID)
return legacyOverride, true
}
}
// Try standalone format: node-VMID
if instance == node {
legacyID := fmt.Sprintf("%s-%d", node, vmid)
if legacyOverride, legacyExists := m.config.Overrides[legacyID]; legacyExists {
log.Info().
Str("legacyID", legacyID).
Str("newID", guestID).
Msg("Migrating guest override from legacy standalone ID format")
// Move to new ID
m.config.Overrides[guestID] = legacyOverride
delete(m.config.Overrides, legacyID)
return legacyOverride, true
}
}
return ThresholdConfig{}, false
}

View file

@ -192,15 +192,19 @@ func (hm *HistoryManager) saveHistoryWithRetry(maxRetries int) error {
historyFile := hm.historyFile
backupFile := hm.backupFile
// Create backup of existing file once before any write attempts.
// This ensures we don't lose data if all retries fail.
backupCreated := false
if _, err := os.Stat(historyFile); err == nil {
if err := os.Rename(historyFile, backupFile); err != nil {
log.Warn().Err(err).Msg("Failed to create backup file")
} else {
backupCreated = true
}
}
var lastErr error
for attempt := 1; attempt <= maxRetries; attempt++ {
// Create backup of existing file before writing
if _, err := os.Stat(historyFile); err == nil {
if err := os.Rename(historyFile, backupFile); err != nil {
log.Warn().Err(err).Msg("Failed to create backup file")
}
}
// Write new file
if err := os.WriteFile(historyFile, data, 0644); err != nil {
lastErr = err
@ -210,13 +214,6 @@ func (hm *HistoryManager) saveHistoryWithRetry(maxRetries int) error {
Int("maxRetries", maxRetries).
Msg("Failed to write history file, will retry")
// Restore backup if write failed
if _, statErr := os.Stat(backupFile); statErr == nil {
if restoreErr := os.Rename(backupFile, historyFile); restoreErr != nil {
log.Error().Err(restoreErr).Msg("Failed to restore backup after write failure")
}
}
// Exponential backoff: 100ms, 200ms, 400ms
if attempt < maxRetries {
backoff := time.Duration(100*(1<<uint(attempt-1))) * time.Millisecond
@ -225,11 +222,23 @@ func (hm *HistoryManager) saveHistoryWithRetry(maxRetries int) error {
continue
}
// Success
// Success - remove backup file now that we've successfully written
if backupCreated {
_ = os.Remove(backupFile)
}
log.Debug().Int("entries", len(snapshot)).Msg("Saved alert history")
return nil
}
// All retries failed - restore backup if we have one
if backupCreated {
if restoreErr := os.Rename(backupFile, historyFile); restoreErr != nil {
log.Error().Err(restoreErr).Msg("Failed to restore backup after all write attempts failed")
} else {
log.Info().Msg("Restored backup after history save failure")
}
}
return fmt.Errorf("failed to write history file after %d attempts: %w", maxRetries, lastErr)
}

View file

@ -3,6 +3,7 @@ package alerts
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
@ -485,9 +486,9 @@ func TestSaveHistory_CreatesBackup(t *testing.T) {
t.Fatalf("saveHistory error: %v", err)
}
// Backup should exist (created from previous file)
if _, err := os.Stat(hm.backupFile); os.IsNotExist(err) {
t.Error("backup file should exist after save with existing main file")
// Backup should be removed after successful save (backup is only for recovery)
if _, err := os.Stat(hm.backupFile); !os.IsNotExist(err) {
t.Error("backup file should be removed after successful save")
}
}
@ -511,18 +512,19 @@ func TestSaveHistoryWithRetry_CreatesBackup(t *testing.T) {
t.Fatalf("saveHistoryWithRetry error: %v", err)
}
// Backup should exist with original content
if _, err := os.Stat(hm.backupFile); os.IsNotExist(err) {
t.Error("backup file should exist after save")
// Backup should be removed after successful save (backup is only for recovery)
if _, err := os.Stat(hm.backupFile); !os.IsNotExist(err) {
t.Error("backup file should be removed after successful save")
}
backupData, err := os.ReadFile(hm.backupFile)
// Verify the main file was written correctly
mainData, err := os.ReadFile(hm.historyFile)
if err != nil {
t.Fatalf("Failed to read backup file: %v", err)
t.Fatalf("Failed to read main file: %v", err)
}
if string(backupData) != existingContent {
t.Errorf("backup content = %s, want %s", backupData, existingContent)
if !strings.Contains(string(mainData), "new-alert") {
t.Errorf("main file should contain new-alert, got: %s", mainData)
}
}

View file

@ -37,7 +37,9 @@ func (h *AlertHandlers) SetMonitor(m *monitoring.Monitor) {
h.monitor = m
}
// validateAlertID validates an alert ID for security
// validateAlertID validates an alert ID for security.
// Alert IDs should be alphanumeric with limited punctuation (hyphens, underscores, colons, periods).
// This prevents issues with logging, URL encoding, and potential injection attacks.
func validateAlertID(alertID string) bool {
// Guard against empty strings or extremely large payloads that could impact memory usage.
if len(alertID) == 0 || len(alertID) > 500 {
@ -49,14 +51,24 @@ func validateAlertID(alertID string) bool {
return false
}
// Permit any printable ASCII character (including spaces) so user supplied identifiers
// like instance names remain valid, while excluding control characters and DEL.
// Allow alphanumeric characters and safe punctuation commonly used in IDs.
// Restrict to a safe character set to avoid issues with logging, URLs, and shell escaping.
for _, r := range alertID {
if r < 32 || r == 127 {
isAlphanumeric := (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')
isSafePunctuation := r == '-' || r == '_' || r == ':' || r == '.' || r == '/' || r == '@'
// Allow spaces for backward compatibility with existing alert IDs that include instance names
isSpace := r == ' '
if !isAlphanumeric && !isSafePunctuation && !isSpace {
return false
}
}
// Reject IDs that start or end with spaces (likely malformed)
if alertID[0] == ' ' || alertID[len(alertID)-1] == ' ' {
return false
}
return true
}
@ -85,9 +97,16 @@ func (h *AlertHandlers) UpdateAlertConfig(w http.ResponseWriter, r *http.Request
// Update notification manager with schedule settings
h.monitor.GetNotificationManager().SetCooldown(config.Schedule.Cooldown)
// Migrate deprecated GroupingWindow to Grouping.Window if needed
groupWindow := config.Schedule.Grouping.Window
if groupWindow == 0 && config.Schedule.GroupingWindow != 0 {
groupWindow = config.Schedule.GroupingWindow
// Migrate the value to the new location
config.Schedule.Grouping.Window = groupWindow
config.Schedule.GroupingWindow = 0 // Clear deprecated field
log.Info().
Int("window", groupWindow).
Msg("Migrated deprecated GroupingWindow to Grouping.Window")
}
h.monitor.GetNotificationManager().SetGroupingWindow(groupWindow)
h.monitor.GetNotificationManager().SetGroupingOptions(