mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
06b7f5a0b9
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.
249 lines
7.9 KiB
Go
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
|
|
}
|