Files
pulse/internal/monitoring/monitor_alert_sync.go
T
rcourtman 06b7f5a0b9 Keep fixture alerts from outliving a mock-mode switch
SetMockMode(false) switched the mock package off and then cleared active
alerts, but every path that evaluates mode-dependent data checked the
mode once, up front. A mock alert pass (started from the ticker with go,
so passes overlap), a frontend or ingest refresh evaluating a GetState
snapshot, a backup-age check over fixture recovery rollups, or a
connection check over the mock ledger could already hold fixture data
when the switch happened. Its evaluations then landed after the clear,
in live mode, where nothing evaluates or resolves fixture resources, so
the alerts stayed until the 24-hour stale sweep.

A pass-level lock around checkMockAlerts was tried and reverted: it left
the other paths open and made the mock-mode API wait behind a whole pass
of synchronous event-log writes.

The monitor now owns a mock-mode epoch fence. The mock pass, backup-age
and snapshot checks, connection checks, Docker pruning and the
resource-store refresh take a scope before reading anything
mode-dependent and send each alert-manager call through it. SetMockMode,
now serialized, flips the mode, ends the epoch, waits only for calls
already admitted, and then clears. Unified metrics are evaluated one
resource per call, so the wait covers single calls, not passes.

Registry rebuilds are tracked rather than waited on. A refresh evaluates
the registry it read back only if no rebuild from an ended epoch
overlapped it, and the readers that open or remove alerts from the
inventory use current state until a clean rebuild in the new epoch
replaces the previous mode's registry. The mock supplemental fixture
sources now serve nothing outside mock mode: the router swaps them for
the real pollers only after the switch, refreshing between sources, so
a single switch to live mode used to raise fixture vSphere incidents.

The new tests park a real evaluation at a fixed point (a held lock or a
blocking supplier), switch modes and release it. Against the unfenced
code the mock pass left about 40 fixture alerts, the frontend refresh 5
vSphere incidents and the backup check 137 backup-age alerts in live
mode, and a mock-mode connection observation counted toward a live
connection's confirmation. The drain, republish and pruning tests fail
against a fence without its wait, overlap check or registry tracking.
Switching mock mode with several tenant monitors running remains
uncovered and is noted in the monitoring contract.
2026-10-07 12:03:38 +01:00

249 lines
7.9 KiB
Go

package monitoring
import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/logging"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// syncAlertsToState copies the latest alert manager data into the shared state snapshot.
// This keeps WebSocket broadcasts aligned with in-memory acknowledgement updates.
func (m *Monitor) syncAlertsToState() {
if m.pruneStaleDockerAlerts() {
if logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Msg("pruned stale docker alerts during sync")
}
}
modelAlerts := m.activeAlertsSnapshot()
for _, alert := range modelAlerts {
if alert.Acknowledged && logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Str("alertID", alert.ID).Interface("ackTime", alert.AckTime).Msg("syncing acknowledged alert")
}
}
m.state.UpdateActiveAlerts(modelAlerts)
recentlyResolved := m.alertManager.GetRecentlyResolved()
if len(recentlyResolved) > 0 && logging.IsLevelEnabled(zerolog.DebugLevel) {
log.Debug().Int("count", len(recentlyResolved)).Msg("syncing recently resolved alerts")
}
m.state.UpdateRecentlyResolved(recentlyResolved)
}
// SyncAlertState is the exported wrapper used by APIs that mutate alerts outside the poll loop.
func (m *Monitor) SyncAlertState() {
m.syncAlertsToState()
}
func (m *Monitor) activeAlertsSnapshot() []models.Alert {
if m == nil {
return nil
}
if m.alertManager == nil {
if m.state == nil {
return nil
}
return m.state.GetSnapshot().ActiveAlerts
}
activeAlerts := m.alertManager.GetActiveAlerts()
modelAlerts := make([]models.Alert, 0, len(activeAlerts))
for _, alert := range activeAlerts {
modelAlerts = append(modelAlerts, models.Alert{
ID: alert.ID,
Type: alert.Type,
Level: string(alert.Level),
ResourceID: alert.ResourceID,
ResourceName: alert.ResourceName,
Node: alert.Node,
NodeDisplayName: alert.NodeDisplayName,
Instance: alert.Instance,
Message: alert.Message,
Value: alert.Value,
Threshold: alert.Threshold,
StartTime: alert.StartTime,
Acknowledged: alert.Acknowledged,
AckTime: alert.AckTime,
AckUser: alert.AckUser,
// GetActiveAlerts returns deep clones, so the map and the live
// metric status are already private.
Metadata: alert.Metadata,
MetricStatus: alert.MetricStatus,
})
}
return modelAlerts
}
func (m *Monitor) recentlyResolvedAlertsSnapshot() []models.ResolvedAlert {
if m == nil {
return nil
}
if m.alertManager == nil {
if m.state == nil {
return nil
}
return m.state.GetSnapshot().RecentlyResolved
}
return m.alertManager.GetRecentlyResolved()
}
// syncUnifiedResourceAlertsToState evaluates resources read under scope. A
// mock-mode flip since that read refuses every remaining step, so a snapshot
// of the mode the monitor left can neither open or resolve alerts nor rewrite
// persisted identities and links in the mode it entered.
func (m *Monitor) syncUnifiedResourceAlertsToState(resources []unifiedresources.Resource, scope mockModeScope) {
if m == nil || m.alertManager == nil {
return
}
// Resource publication can make persisted canonical policy resolvable for
// restored native-ID alerts that preceded the first observation at startup.
scope.run(func() { m.alertManager.ReconcileOperatorIntentState() })
scope.run(func() { m.migrateAlertIdentities(resources) })
scope.run(func() { m.migrateAvailabilityLinksToCanonicalIDs(resources) })
// Metrics go one resource per fenced call, as
// CheckUnifiedResourceMetricsWithCapacityTrends would iterate them, so a
// mode change waits for one resource's evaluation rather than the estate's.
capacityTrends := m.unifiedStorageCapacityTrends(resources, time.Now())
for _, resource := range resources {
input, ok := alerts.UnifiedResourceInputFromResource(resource)
if !ok {
continue
}
scope.run(func() { m.alertManager.CheckUnifiedResourceWithCapacityTrend(input, capacityTrends[input.ID]) })
}
scope.run(func() { m.alertManager.SyncUnifiedResourceIncidents(resources) })
m.syncAlertsToState()
}
// migrateAlertIdentities persists the alert identity migration that resources
// make resolvable.
func (m *Monitor) migrateAlertIdentities(resources []unifiedresources.Resource) {
config := m.alertManager.GetConfig()
migrationPlan := alerts.PlanAlertIdentityMigration(config, resources)
if migrationPlan.UnsupportedVersion {
log.Warn().
Int("configVersion", migrationPlan.FromVersion).
Int("supportedVersion", alerts.CurrentAlertIdentitySchemaVersion).
Msg("alert identity config uses a newer schema; leaving persisted identities untouched")
} else if alerts.ApplyAlertIdentityMigration(&config, migrationPlan) {
if m.configPersist == nil {
log.Warn().
Int("fromVersion", migrationPlan.FromVersion).
Int("toVersion", migrationPlan.ToVersion).
Int("removedOverrides", len(migrationPlan.RemovedOverrideKeys)).
Int("addedOverrides", len(migrationPlan.AddedOverrideKeys)).
Int("deferredOverrides", len(migrationPlan.Deferred)).
Msg("cannot persist planned alert identity migration without config persistence")
} else if err := m.configPersist.SaveAlertConfig(config); err != nil {
log.Error().
Err(err).
Int("fromVersion", migrationPlan.FromVersion).
Int("toVersion", migrationPlan.ToVersion).
Msg("failed to persist planned alert identity migration")
} else {
m.alertManager.UpdateConfig(config)
log.Info().
Int("fromVersion", migrationPlan.FromVersion).
Int("toVersion", migrationPlan.ToVersion).
Int("removedOverrides", len(migrationPlan.RemovedOverrideKeys)).
Int("addedOverrides", len(migrationPlan.AddedOverrideKeys)).
Int("deferredOverrides", len(migrationPlan.Deferred)).
Msg("persisted alert identity migration plan")
}
}
}
// pruneStaleDockerAlerts removes docker alerts that reference hosts no longer present in state.
func (m *Monitor) pruneStaleDockerAlerts() bool {
if m.alertManager == nil {
return false
}
// The inventory is the fixture read view in mock mode. A prune that read
// it must not remove Docker alerts the other mode raised after a switch.
scope := m.mockModeFence.begin()
readState := m.currentModeReadState()
if readState == nil {
return false
}
hosts := readState.DockerHosts()
knownHosts := make(map[string]struct{}, len(hosts)*2)
for _, host := range hosts {
hostID := strings.TrimSpace(host.ID())
if hostID != "" {
knownHosts[hostID] = struct{}{}
}
if sourceID := strings.TrimSpace(host.HostSourceID()); sourceID != "" {
knownHosts[sourceID] = struct{}{}
}
}
if len(knownHosts) == 0 {
// Still allow stale entries to be cleared if no hosts remain.
}
active := m.alertManager.GetActiveAlerts()
processed := make(map[string]struct{})
cleared := false
for _, alert := range active {
var hostID string
switch {
case alert.Type == "docker-host-offline":
hostID = strings.TrimPrefix(strings.TrimSpace(alert.ResourceID), "docker:")
case strings.HasPrefix(alert.ResourceID, "docker:"):
resource := strings.TrimPrefix(alert.ResourceID, "docker:")
if idx := strings.Index(resource, "/"); idx >= 0 {
hostID = resource[:idx]
} else {
hostID = resource
}
default:
continue
}
hostID = strings.TrimSpace(hostID)
if hostID == "" {
continue
}
if _, known := knownHosts[hostID]; known {
continue
}
if _, alreadyCleared := processed[hostID]; alreadyCleared {
continue
}
host := models.DockerHost{
ID: hostID,
DisplayName: alert.ResourceName,
Hostname: alert.Node,
}
if host.DisplayName == "" {
host.DisplayName = hostID
}
if host.Hostname == "" {
host.Hostname = hostID
}
if !scope.run(func() { m.alertManager.HandleDockerHostRemoved(host) }) {
return cleared
}
processed[hostID] = struct{}{}
cleared = true
}
return cleared
}