Files
pulse/internal/monitoring/monitor_alert_intent.go
T
rcourtman 62c3761ee6 Preserve alert lifecycle truth and safe pinned updates
Rejected active-alert writes still reached history, firing events and
notification dispatch. Make admission explicit across detector and restore
paths so repeated suppressed observations cannot create those side effects.
Reconcile restored alerts when persisted resource policy attaches at startup,
reconcile again after native aliases become canonical, and refresh shared
state before those alerts reach clients.

Preserve occurrence identity when retained alerts re-fire, including durable
history reconstruction and delayed lifecycle replay.

Keep retained table and card components mounted when scrolling shifts the
visible window, so policy editors do not lose unsaved state. Show actual
future maintenance times and the paused-attention effect.

Keep synthetic host machine identities stable across restarts so saved
operator policy remains attached during verification.

Qualify server update and rollback instructions by helper ownership, and
stop signed installer recipes before execution if a download or signature
check fails.

Refs #2237
2026-09-24 17:43:50 +01:00

134 lines
4.9 KiB
Go

package monitoring
import (
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
"github.com/rs/zerolog/log"
)
const backupIntentEvidenceMaxAge = 5 * time.Minute
type resourceOperatorIntentReader interface {
GetResourceOperatorState(canonicalID string) (unifiedresources.ResourceOperatorState, bool, error)
}
type resourceIntentIdentityReader interface {
ResolveCanonicalResourceID(ref string) (string, bool)
}
type resourceIntentAncestorReader interface {
ResolveCanonicalResourceAncestors(ref string) []string
}
func (m *Monitor) installOperatorIntentResolver(store ResourceStoreInterface) {
if m == nil || m.alertManager == nil {
return
}
identityReader, hasIdentityReader := store.(resourceIntentIdentityReader)
if hasIdentityReader && identityReader != nil {
m.alertManager.SetResourceIntentIdentityResolver(identityReader.ResolveCanonicalResourceID)
} else {
m.alertManager.SetResourceIntentIdentityResolver(nil)
}
reader, ok := store.(resourceOperatorIntentReader)
if !ok || reader == nil {
m.alertManager.SetOperatorIntentContextResolver(nil)
return
}
ancestorReader, hasAncestorReader := store.(resourceIntentAncestorReader)
m.alertManager.SetOperatorIntentContextResolver(func(resourceID string, now time.Time) (alerts.OperatorIntentContext, bool) {
if hasIdentityReader {
if canonicalID, found := identityReader.ResolveCanonicalResourceID(resourceID); found {
resourceID = canonicalID
}
}
state, found, err := reader.GetResourceOperatorState(resourceID)
if err != nil {
log.Warn().Err(err).Str("resourceID", resourceID).Msg("Failed to read operator state for alert intent")
return alerts.OperatorIntentContext{}, false
}
context := alerts.OperatorIntentContext{}
if found {
context = alerts.OperatorIntentContext{
IntentionallyOffline: state.IntentionallyOffline,
MonitoringMode: string(state.MonitoringMode),
LifecycleState: string(state.LifecycleState),
}
}
applyActiveMaintenance := func(candidate unifiedresources.ResourceOperatorState, sourceID string, inherited bool) {
occurrence, active := candidate.ActiveMaintenanceOccurrenceAt(now)
if !active {
return
}
// Overlapping exact/inherited schedules remain suppressed until the
// latest active end. This avoids promising an early delivery resume.
if context.MaintenanceEndAt != nil && !occurrence.EndAt.After(*context.MaintenanceEndAt) {
return
}
startAt, endAt := occurrence.StartAt, occurrence.EndAt
context.MaintenanceStartAt = &startAt
context.MaintenanceEndAt = &endAt
context.MaintenanceReason = candidate.MaintenanceReason
context.MaintenanceSourceID = sourceID
context.MaintenanceInherited = inherited
context.MaintenanceScope = string(candidate.MaintenanceScope)
}
if found {
applyActiveMaintenance(state, resourceID, false)
}
if hasAncestorReader {
for _, ancestorID := range ancestorReader.ResolveCanonicalResourceAncestors(resourceID) {
ancestorState, ancestorFound, ancestorErr := reader.GetResourceOperatorState(ancestorID)
if ancestorErr != nil {
log.Warn().Err(ancestorErr).Str("resourceID", resourceID).Str("ancestorID", ancestorID).Msg("Failed to read inherited operator maintenance state")
continue
}
if !ancestorFound || ancestorState.MaintenanceScope != unifiedresources.MaintenanceScopeResourceAndDescendants {
continue
}
applyActiveMaintenance(ancestorState, ancestorID, true)
}
}
return context, found || context.MaintenanceEndAt != nil
})
// Alerts restore before the resource store is attached during startup.
// Reconcile that restored set as soon as persisted policy is available.
if m.alertManager.ReconcileOperatorIntentState() > 0 && m.state != nil {
m.SyncAlertState()
}
}
func (m *Monitor) resolveBackupIntentContext(_ string, instance, node string, vmid int, now time.Time) (alerts.BackupIntentContext, bool) {
if m == nil || m.state == nil || vmid <= 0 {
return alerts.BackupIntentContext{}, false
}
instance = strings.TrimSpace(instance)
node = strings.TrimSpace(node)
if now.IsZero() {
now = time.Now().UTC()
}
for _, task := range m.state.GetSnapshot().PVEBackups.BackupTasks {
if task.VMID != vmid || (instance != "" && task.Instance != instance) || (node != "" && task.Node != "" && task.Node != node) {
continue
}
if task.ObservedAt.IsZero() || now.Sub(task.ObservedAt) > backupIntentEvidenceMaxAge || task.ObservedAt.After(now.Add(time.Minute)) {
continue
}
status := strings.ToLower(strings.TrimSpace(task.Status))
if !task.EndTime.IsZero() || status == "ok" || status == "stopped" || status == "error" || status == "warning" {
continue
}
return alerts.BackupIntentContext{
Active: true,
ObservedAt: task.ObservedAt,
Evidence: "pve_vzdump_task:" + task.ID,
}, true
}
return alerts.BackupIntentContext{}, false
}