package alerting import ( "context" "errors" "log/slog" "time" "github.com/syncova/syncova/packages/platform/crypto" "github.com/syncova/syncova/packages/platform/logging" ) // LoopOptions steuern die Auswertungsschleife. type LoopOptions struct { // Interval ist der Abstand zweier Auswertungen. // // Eine Minute: Kurz genug, damit ein Ausfall zeitnah auffaellt, lang genug, // dass die Datenbank es nicht merkt. Die Auswertung liest nur. Interval time.Duration // ResolvedRetention ist die Aufbewahrungsfrist erledigter Meldungen. ResolvedRetention time.Duration // NotificationsEnabled schaltet die Zustellung ein. // // Getrennt von der Auswertung: Eine Anlage soll Meldungen erzeugen koennen, // ohne dass jemand nachts eine Mail bekommt — etwa waehrend der Einrichtung. NotificationsEnabled bool } // Standardwerte der Schleife. const ( // defaultEvaluationInterval ist der Standardabstand der Auswertung. defaultEvaluationInterval = time.Minute // defaultResolvedRetention ist die Standardaufbewahrung erledigter Meldungen. // // Dreissig Tage: lang genug fuer die Frage „was war letzten Monat los?", // kurz genug, dass die Tabelle nicht waechst. defaultResolvedRetention = 30 * 24 * time.Hour ) // applyDefaults fuellt fehlende Werte. func (options *LoopOptions) applyDefaults() { if options.Interval <= 0 { options.Interval = defaultEvaluationInterval } if options.ResolvedRetention <= 0 { options.ResolvedRetention = defaultResolvedRetention } } // Loop wertet die Regeln regelmaessig aus und stellt neue Meldungen zu. type Loop struct { // store legt die Meldungen ab. store *Store // evaluator prueft die Regeln. evaluator *Evaluator // dispatcher stellt Meldungen zu. dispatcher Deliverer // secretStore entschluesselt die Zugangsgeheimnisse der Kanaele. secretStore crypto.SecretStore // options sind die Einstellungen. options LoopOptions // logger protokolliert den Verlauf. logger *slog.Logger } // NewLoop erzeugt die Auswertungsschleife. func NewLoop(store *Store, evaluator *Evaluator, dispatcher Deliverer, secretStore crypto.SecretStore, options LoopOptions, baseLogger *slog.Logger) (*Loop, error) { if store == nil { return nil, errors.New("die auswertung braucht eine datenzugriffsschicht") } if evaluator == nil { return nil, errors.New("die auswertung braucht eine regelpruefung") } options.applyDefaults() return &Loop{ store: store, evaluator: evaluator, dispatcher: dispatcher, secretStore: secretStore, options: options, logger: logging.WithComponent(baseLogger, "alerting"), }, nil } // Run laeuft bis zum Abbruch des Kontexts. func (loop *Loop) Run(runContext context.Context) error { loop.logger.Info("die meldungsauswertung beginnt", slog.Duration("abstand", loop.options.Interval), slog.Int("regeln", AvailableRuleCount()), slog.Bool("zustellung", loop.options.NotificationsEnabled)) // Der erste Durchgang laeuft sofort. Sonst blieben nach einem Neustart alle // bestehenden Zustaende eine Minute lang unbemerkt — und ein Neustart // geschieht oft genau dann, wenn etwas nicht stimmt. loop.evaluateOnce(runContext) evaluationTicker := time.NewTicker(loop.options.Interval) defer evaluationTicker.Stop() pruneTicker := time.NewTicker(24 * time.Hour) defer pruneTicker.Stop() for { select { case <-runContext.Done(): loop.logger.Info("die meldungsauswertung wird beendet") return nil case <-evaluationTicker.C: loop.evaluateOnce(runContext) case <-pruneTicker.C: loop.pruneResolved(runContext) } } } // evaluateOnce wertet alle Regeln einmal aus. func (loop *Loop) evaluateOnce(evaluateContext context.Context) { ruleResults, evaluateError := loop.evaluator.EvaluateAll(evaluateContext) if evaluateError != nil { loop.logger.Error("die regeln liessen sich nicht auswerten", slog.String("grund", evaluateError.Error())) return } for _, ruleResult := range ruleResults { loop.applyRuleResult(evaluateContext, ruleResult) } } // applyRuleResult schreibt die Befunde einer Regel fort. func (loop *Loop) applyRuleResult(applyContext context.Context, ruleResult RuleResult) { activeFingerprints := make([]string, 0, len(ruleResult.Findings)) for _, finding := range ruleResult.Findings { activeFingerprints = append(activeFingerprints, finding.Fingerprint) raisedAlert, isNew, raiseError := loop.store.RaiseFinding(applyContext, finding) if raiseError != nil { loop.logger.Error("eine meldung konnte nicht angelegt werden", slog.String("regel", finding.RuleName), slog.String("grund", raiseError.Error())) continue } if !isNew { // Eine bestehende Meldung wurde nur aktualisiert. Sie erneut // zuzustellen hiesse, alle fuenf Minuten dieselbe Mail zu schicken, // solange der Zustand anhaelt. continue } loop.logger.Warn("neue meldung", slog.String("regel", raisedAlert.RuleName), slog.String("schweregrad", string(raisedAlert.Severity)), slog.String("titel", raisedAlert.Title), slog.String("meldung_id", raisedAlert.ID.String())) if loop.options.NotificationsEnabled { loop.deliverAlert(applyContext, *raisedAlert) } } // Was nicht mehr zutrifft, wird aufgeloest. **Der wichtigste Schritt:** Ohne // ihn bleibt jede Meldung stehen, bis jemand sie wegklickt — und nach zwei // Wochen geht die aktuelle in einer Liste erledigter Probleme unter. resolvedCount, resolveError := loop.store.ResolveVanishedFindings(applyContext, ruleResult.RuleName, activeFingerprints) if resolveError != nil { loop.logger.Error("erledigte meldungen liessen sich nicht aufloesen", slog.String("regel", ruleResult.RuleName), slog.String("grund", resolveError.Error())) return } if resolvedCount > 0 { loop.logger.Info("meldungen haben sich von selbst erledigt", slog.String("regel", ruleResult.RuleName), slog.Int("anzahl", resolvedCount)) } } // deliverAlert stellt eine Meldung ueber alle passenden Kanaele zu. func (loop *Loop) deliverAlert(deliverContext context.Context, alert Alert) { if loop.dispatcher == nil { return } matchingChannels, listError := loop.store.EnabledChannelsFor(deliverContext, alert.Severity) if listError != nil { loop.logger.Error("die benachrichtigungskanaele liessen sich nicht lesen", slog.String("grund", listError.Error())) return } for _, channel := range matchingChannels { alreadyDelivered, checkError := loop.store.WasDelivered(deliverContext, alert.ID, channel.ID) if checkError != nil { loop.logger.Error("die zustellung liess sich nicht pruefen", slog.String("kanal", channel.Name), slog.String("grund", checkError.Error())) continue } if alreadyDelivered { continue } channelSecret, secretError := loop.store.LoadChannelSecret(deliverContext, channel.ID, loop.secretStore) if secretError != nil { loop.logger.Error("das zugangsgeheimnis des kanals liess sich nicht lesen", slog.String("kanal", channel.Name), slog.String("grund", secretError.Error())) continue } deliveryError := loop.dispatcher.Deliver(deliverContext, channel, channelSecret, alert) if recordError := loop.store.RecordDelivery(deliverContext, alert.ID, channel.ID, deliveryError); recordError != nil { loop.logger.Error("die zustellung konnte nicht vermerkt werden", slog.String("kanal", channel.Name), slog.String("grund", recordError.Error())) } if deliveryError != nil { // Eine fehlgeschlagene Zustellung wird protokolliert und am Kanal // vermerkt, aber nicht wiederholt: Ein nicht erreichbarer Empfaenger // bleibt es meist auch beim zweiten Versuch, und die Schleife soll // nicht daran haengen. Der Kanalzustand macht das Problem sichtbar. loop.logger.Error("eine meldung konnte nicht zugestellt werden", slog.String("kanal", channel.Name), slog.String("meldung", alert.Title), slog.String("grund", deliveryError.Error())) continue } loop.logger.Info("meldung zugestellt", slog.String("kanal", channel.Name), slog.String("meldung_id", alert.ID.String())) } } // pruneResolved entfernt lange erledigte Meldungen. func (loop *Loop) pruneResolved(pruneContext context.Context) { removedCount, pruneError := loop.store.PruneResolvedAlerts(pruneContext, loop.options.ResolvedRetention) if pruneError != nil { loop.logger.Error("erledigte meldungen liessen sich nicht entfernen", slog.String("grund", pruneError.Error())) return } if removedCount > 0 { loop.logger.Info("erledigte meldungen entfernt", slog.Int64("anzahl", removedCount)) } }