package alerting import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) // Status ist der Bearbeitungszustand einer Meldung. type Status string const ( // StatusOpen ist unbearbeitet. StatusOpen Status = "open" // StatusAcknowledged wurde zur Kenntnis genommen. // // **Nicht erledigt.** Eine bestaetigte Meldung bleibt bestehen, bis ihre // Ursache verschwindet — sonst waere „bestaetigen" nur ein anderes Wort fuer // „wegklicken", und der Zustand bliebe unbemerkt. StatusAcknowledged Status = "acknowledged" // StatusResolved ist erledigt. StatusResolved Status = "resolved" ) // Alert ist eine Meldung. type Alert struct { // ID ist der oeffentliche Bezeichner. ID uuid.UUID `json:"id"` // RuleName benennt die ausloesende Regel. RuleName string `json:"rule_name"` // Severity ist der Schweregrad. Severity Severity `json:"severity"` // Status ist der Bearbeitungszustand. Status Status `json:"status"` // Fingerprint kennzeichnet die Ursache eindeutig. Fingerprint string `json:"fingerprint"` // Title ist die Ueberschrift. Title string `json:"title"` // Message erklaert den Befund und die naechste Handlung. Message string `json:"message"` // EntityType benennt die Art des betroffenen Gegenstands. EntityType string `json:"entity_type,omitempty"` // EntityID ist der betroffene Gegenstand. EntityID *uuid.UUID `json:"entity_id,omitempty"` // EntityName ist sein sprechender Name. EntityName string `json:"entity_name,omitempty"` // Details traegt die Messwerte des Befunds. Details map[string]any `json:"details,omitempty"` // OccurrenceCount zaehlt, wie oft die Ursache seither auftrat. OccurrenceCount int `json:"occurrence_count"` // FirstSeenAt ist das erste Auftreten in UTC. FirstSeenAt time.Time `json:"first_seen_at"` // LastSeenAt ist das letzte Auftreten in UTC. LastSeenAt time.Time `json:"last_seen_at"` // AcknowledgedAt ist der Zeitpunkt der Kenntnisnahme in UTC. AcknowledgedAt *time.Time `json:"acknowledged_at,omitempty"` // AcknowledgedBy benennt den Bestaetigenden. AcknowledgedBy *uuid.UUID `json:"acknowledged_by,omitempty"` // AcknowledgementNote haelt eine Bemerkung fest. AcknowledgementNote string `json:"acknowledgement_note,omitempty"` // ResolvedAt ist der Zeitpunkt der Aufloesung in UTC. ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedBy benennt den Aufloesenden; nil bei automatischer Aufloesung. ResolvedBy *uuid.UUID `json:"resolved_by,omitempty"` // ResolutionNote begruendet die Aufloesung. ResolutionNote string `json:"resolution_note,omitempty"` // CreatedAt ist der Anlagezeitpunkt in UTC. CreatedAt time.Time `json:"created_at"` } // WasResolvedAutomatically meldet eine von selbst verschwundene Ursache. // // Der Unterschied zur Aufloesung von Hand ist eine Auskunft ueber die Anlage: // Was sich selbst erledigt, war ein Zwischenfall. Was jemand wegklicken musste, // war eine Entscheidung. func (alert *Alert) WasResolvedAutomatically() bool { return alert.Status == StatusResolved && alert.ResolvedBy == nil } // Fehler der Datenzugriffsschicht. var ( // ErrAlertNotFound meldet eine nicht vorhandene Meldung. ErrAlertNotFound = errors.New("die meldung wurde nicht gefunden") // ErrAlertNotOpen meldet eine bereits erledigte Meldung. ErrAlertNotOpen = errors.New("die meldung ist bereits erledigt") // ErrChannelNotFound meldet einen nicht vorhandenen Kanal. ErrChannelNotFound = errors.New("der benachrichtigungskanal wurde nicht gefunden") ) // Store legt Meldungen in PostgreSQL ab. type Store struct { // connectionPool ist der Datenbankpool der Control Plane. connectionPool *pgxpool.Pool } // NewStore erzeugt die Datenzugriffsschicht. func NewStore(connectionPool *pgxpool.Pool) *Store { return &Store{connectionPool: connectionPool} } // alertColumnList sind die Spalten einer Meldung in fester Reihenfolge. const alertColumnList = ` id, rule_name, severity, status, fingerprint, title, message, entity_type, entity_id, entity_name, details, occurrence_count, first_seen_at, last_seen_at, acknowledged_at, acknowledged_by, acknowledgement_note, resolved_at, resolved_by, resolution_note, created_at` // rowScanner deckt QueryRow und Rows gemeinsam ab. type rowScanner interface { // Scan liest die Spalten einer Zeile. Scan(destinations ...any) error } // scanAlert liest eine Meldungszeile. func scanAlert(scanner rowScanner) (*Alert, error) { var ( loadedAlert Alert severityText string statusText string entityType *string entityName *string detailsJSON []byte acknowledgementNote *string resolutionNote *string ) scanError := scanner.Scan( &loadedAlert.ID, &loadedAlert.RuleName, &severityText, &statusText, &loadedAlert.Fingerprint, &loadedAlert.Title, &loadedAlert.Message, &entityType, &loadedAlert.EntityID, &entityName, &detailsJSON, &loadedAlert.OccurrenceCount, &loadedAlert.FirstSeenAt, &loadedAlert.LastSeenAt, &loadedAlert.AcknowledgedAt, &loadedAlert.AcknowledgedBy, &acknowledgementNote, &loadedAlert.ResolvedAt, &loadedAlert.ResolvedBy, &resolutionNote, &loadedAlert.CreatedAt, ) if scanError != nil { return nil, scanError } loadedAlert.Severity = Severity(severityText) loadedAlert.Status = Status(statusText) for target, source := range map[*string]*string{ &loadedAlert.EntityType: entityType, &loadedAlert.EntityName: entityName, &loadedAlert.AcknowledgementNote: acknowledgementNote, &loadedAlert.ResolutionNote: resolutionNote, } { if source != nil { *target = *source } } if len(detailsJSON) > 0 { _ = json.Unmarshal(detailsJSON, &loadedAlert.Details) } return &loadedAlert, nil } // Finding ist ein von einer Regel erkannter Zustand. type Finding struct { // RuleName benennt die erkennende Regel. RuleName string // Severity ist der Schweregrad. Severity Severity // Fingerprint kennzeichnet die Ursache eindeutig. Fingerprint string // Title ist die Ueberschrift. Title string // Message erklaert den Befund und die naechste Handlung. Message string // EntityType benennt die Art des betroffenen Gegenstands. EntityType string // EntityID ist der betroffene Gegenstand. EntityID *uuid.UUID // EntityName ist sein sprechender Name. EntityName string // Details traegt die Messwerte des Befunds. Details map[string]any } // RaiseFinding legt eine Meldung an oder aktualisiert die bestehende. // // Der zweite Rueckgabewert meldet, ob die Meldung neu ist. Nur eine neue wird // zugestellt: Sonst bekaeme der Bereitschaftsdienst alle fuenf Minuten dieselbe // Mail, solange der Zustand anhaelt. func (store *Store) RaiseFinding(raiseContext context.Context, finding Finding) (*Alert, bool, error) { detailsJSON, encodeError := json.Marshal(finding.Details) if encodeError != nil { return nil, false, fmt.Errorf("die messwerte konnten nicht abgelegt werden: %w", encodeError) } // Ein einziges Statement statt Nachsehen-und-Schreiben: Zwischen einer // Abfrage und einem Insert passt ein zweiter Control-Server, und dann // entstuenden zwei Meldungen zur selben Ursache. Der Teilindex verhindert // das, und ON CONFLICT macht daraus die gewuenschte Aktualisierung. const upsertStatement = ` INSERT INTO alerts (rule_name, severity, fingerprint, title, message, entity_type, entity_id, entity_name, details) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (fingerprint) WHERE status IN ('open', 'acknowledged') DO UPDATE SET last_seen_at = now(), occurrence_count = alerts.occurrence_count + 1, -- Der Schweregrad darf steigen: Aus einer Warnung kann ein -- kritischer Zustand werden, waehrend die Meldung offen ist. severity = CASE WHEN EXCLUDED.severity = 'critical' THEN 'critical' WHEN EXCLUDED.severity = 'high' AND alerts.severity IN ('information', 'warning') THEN 'high' ELSE alerts.severity END, message = EXCLUDED.message, details = EXCLUDED.details RETURNING ` + alertColumnList + `, (xmax = 0) AS ist_neu` var ( raisedAlert *Alert isNew bool ) resultRows, queryError := store.connectionPool.Query(raiseContext, upsertStatement, finding.RuleName, string(finding.Severity), finding.Fingerprint, finding.Title, finding.Message, nullableString(finding.EntityType), finding.EntityID, nullableString(finding.EntityName), detailsJSON) if queryError != nil { return nil, false, fmt.Errorf("die meldung konnte nicht angelegt werden: %w", queryError) } defer resultRows.Close() if resultRows.Next() { var ( loadedAlert Alert severityText string statusText string entityType *string entityName *string detailsBytes []byte acknowledgementNote *string resolutionNote *string ) if scanError := resultRows.Scan( &loadedAlert.ID, &loadedAlert.RuleName, &severityText, &statusText, &loadedAlert.Fingerprint, &loadedAlert.Title, &loadedAlert.Message, &entityType, &loadedAlert.EntityID, &entityName, &detailsBytes, &loadedAlert.OccurrenceCount, &loadedAlert.FirstSeenAt, &loadedAlert.LastSeenAt, &loadedAlert.AcknowledgedAt, &loadedAlert.AcknowledgedBy, &acknowledgementNote, &loadedAlert.ResolvedAt, &loadedAlert.ResolvedBy, &resolutionNote, &loadedAlert.CreatedAt, &isNew, ); scanError != nil { return nil, false, fmt.Errorf("die meldung konnte nicht gelesen werden: %w", scanError) } loadedAlert.Severity = Severity(severityText) loadedAlert.Status = Status(statusText) if entityType != nil { loadedAlert.EntityType = *entityType } if entityName != nil { loadedAlert.EntityName = *entityName } if len(detailsBytes) > 0 { _ = json.Unmarshal(detailsBytes, &loadedAlert.Details) } raisedAlert = &loadedAlert } if rowsError := resultRows.Err(); rowsError != nil { return nil, false, rowsError } if raisedAlert == nil { return nil, false, errors.New("die meldung wurde weder angelegt noch aktualisiert") } return raisedAlert, isNew, nil } // nullableString wandelt eine leere Zeichenkette in NULL. func nullableString(value string) *string { if value == "" { return nil } return &value } // ResolveVanishedFindings loest Meldungen einer Regel auf, deren Ursache fehlt. // // **Der wichtigste Vorgang des Meldungswesens.** Ohne ihn bleibt jede Meldung // stehen, bis jemand sie wegklickt; nach zwei Wochen steht dort eine Liste // erledigter Probleme, und die eine aktuelle geht darin unter. // // Aufgeloest wird nur, was diese Regel selbst erzeugt hat und was im aktuellen // Durchgang **nicht** mehr auftrat. func (store *Store) ResolveVanishedFindings(resolveContext context.Context, ruleName string, activeFingerprints []string) (int, error) { const resolveStatement = ` UPDATE alerts SET status = 'resolved', resolved_at = now(), resolution_note = 'Die Ursache besteht nicht mehr.' WHERE rule_name = $1 AND status IN ('open', 'acknowledged') AND NOT (fingerprint = ANY($2))` // Ein leeres Array statt NULL: `x = ANY(NULL)` ergibt NULL, und die // Bedingung waere nie wahr — es wuerde nichts aufgeloest. fingerprintList := activeFingerprints if fingerprintList == nil { fingerprintList = []string{} } commandTag, execError := store.connectionPool.Exec(resolveContext, resolveStatement, ruleName, fingerprintList) if execError != nil { return 0, fmt.Errorf("erledigte meldungen konnten nicht aufgeloest werden: %w", execError) } return int(commandTag.RowsAffected()), nil } // AlertFilter schraenkt die Liste der Meldungen ein. type AlertFilter struct { // Status beschraenkt auf einen Bearbeitungszustand. Status string // Severity beschraenkt auf einen Schweregrad. Severity string // OnlyActive zeigt nur unerledigte Meldungen. OnlyActive bool // Page ist die angeforderte Seite. Page int // PageSize ist die Seitengroesse. PageSize int } // ListAlerts liefert eine Seite von Meldungen. func (store *Store) ListAlerts(listContext context.Context, filter AlertFilter) ([]Alert, int, error) { if filter.Page < 1 { filter.Page = 1 } if filter.PageSize < 1 || filter.PageSize > 200 { filter.PageSize = 50 } const selectStatement = ` SELECT ` + alertColumnList + `, COUNT(*) OVER () AS total_count FROM alerts WHERE ($1::text IS NULL OR status = $1) AND ($2::text IS NULL OR severity = $2) AND (NOT $3::bool OR status IN ('open', 'acknowledged')) ORDER BY -- Unerledigte zuerst, danach nach Schweregrad und Aktualitaet. Eine -- rein zeitliche Sortierung schoebe die kritische Meldung von gestern -- unter die Auskunft von heute Morgen. CASE status WHEN 'open' THEN 0 WHEN 'acknowledged' THEN 1 ELSE 2 END, CASE severity WHEN 'critical' THEN 0 WHEN 'high' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END, last_seen_at DESC LIMIT $4 OFFSET $5` alertRows, queryError := store.connectionPool.Query(listContext, selectStatement, nullableString(filter.Status), nullableString(filter.Severity), filter.OnlyActive, filter.PageSize, (filter.Page-1)*filter.PageSize) if queryError != nil { return nil, 0, fmt.Errorf("die meldungen konnten nicht gelesen werden: %w", queryError) } defer alertRows.Close() loadedAlerts := make([]Alert, 0, filter.PageSize) var totalCount int for alertRows.Next() { var ( loadedAlert Alert severityText string statusText string entityType *string entityName *string detailsBytes []byte acknowledgementNote *string resolutionNote *string ) if scanError := alertRows.Scan( &loadedAlert.ID, &loadedAlert.RuleName, &severityText, &statusText, &loadedAlert.Fingerprint, &loadedAlert.Title, &loadedAlert.Message, &entityType, &loadedAlert.EntityID, &entityName, &detailsBytes, &loadedAlert.OccurrenceCount, &loadedAlert.FirstSeenAt, &loadedAlert.LastSeenAt, &loadedAlert.AcknowledgedAt, &loadedAlert.AcknowledgedBy, &acknowledgementNote, &loadedAlert.ResolvedAt, &loadedAlert.ResolvedBy, &resolutionNote, &loadedAlert.CreatedAt, &totalCount, ); scanError != nil { return nil, 0, fmt.Errorf("eine meldung konnte nicht gelesen werden: %w", scanError) } loadedAlert.Severity = Severity(severityText) loadedAlert.Status = Status(statusText) if entityType != nil { loadedAlert.EntityType = *entityType } if entityName != nil { loadedAlert.EntityName = *entityName } if acknowledgementNote != nil { loadedAlert.AcknowledgementNote = *acknowledgementNote } if resolutionNote != nil { loadedAlert.ResolutionNote = *resolutionNote } if len(detailsBytes) > 0 { _ = json.Unmarshal(detailsBytes, &loadedAlert.Details) } loadedAlerts = append(loadedAlerts, loadedAlert) } return loadedAlerts, totalCount, alertRows.Err() } // GetAlert liest eine Meldung. func (store *Store) GetAlert(readContext context.Context, alertIdentifier uuid.UUID) (*Alert, error) { const selectStatement = `SELECT ` + alertColumnList + ` FROM alerts WHERE id = $1` loadedAlert, scanError := scanAlert(store.connectionPool.QueryRow(readContext, selectStatement, alertIdentifier)) if errors.Is(scanError, pgx.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrAlertNotFound, alertIdentifier) } if scanError != nil { return nil, fmt.Errorf("die meldung konnte nicht gelesen werden: %w", scanError) } return loadedAlert, nil } // AcknowledgeAlert nimmt eine Meldung zur Kenntnis. // // Die Meldung bleibt **offen**. „Bestaetigen" heisst „ich weiss davon", nicht // „weg damit": Waere es dasselbe, verschwaende der Zustand aus der Uebersicht, // obwohl er weiterbesteht. func (store *Store) AcknowledgeAlert(acknowledgeContext context.Context, alertIdentifier uuid.UUID, acknowledgedBy uuid.UUID, note string) (*Alert, error) { const updateStatement = ` UPDATE alerts SET status = 'acknowledged', acknowledged_at = now(), acknowledged_by = $2, acknowledgement_note = $3 WHERE id = $1 AND status = 'open' RETURNING ` + alertColumnList updatedAlert, scanError := scanAlert(store.connectionPool.QueryRow(acknowledgeContext, updateStatement, alertIdentifier, acknowledgedBy, nullableString(note))) if errors.Is(scanError, pgx.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrAlertNotOpen, alertIdentifier) } if scanError != nil { return nil, fmt.Errorf("die meldung konnte nicht bestaetigt werden: %w", scanError) } return updatedAlert, nil } // ResolveAlert erledigt eine Meldung von Hand. // // Der Weg ist fuer den Fall gedacht, dass die Anlage die Ursache nicht selbst // als verschwunden erkennt. Er wird mit Verursacher festgehalten: Eine von // Hand geschlossene Meldung ist etwas anderes als eine verschwundene Ursache. func (store *Store) ResolveAlert(resolveContext context.Context, alertIdentifier uuid.UUID, resolvedBy uuid.UUID, note string) (*Alert, error) { const updateStatement = ` UPDATE alerts SET status = 'resolved', resolved_at = now(), resolved_by = $2, resolution_note = $3 WHERE id = $1 AND status IN ('open', 'acknowledged') RETURNING ` + alertColumnList updatedAlert, scanError := scanAlert(store.connectionPool.QueryRow(resolveContext, updateStatement, alertIdentifier, resolvedBy, nullableString(note))) if errors.Is(scanError, pgx.ErrNoRows) { return nil, fmt.Errorf("%w: %s", ErrAlertNotOpen, alertIdentifier) } if scanError != nil { return nil, fmt.Errorf("die meldung konnte nicht aufgeloest werden: %w", scanError) } return updatedAlert, nil } // AlertSummary fasst die Meldungslage zusammen. type AlertSummary struct { // OpenCount ist die Zahl unbearbeiteter Meldungen. OpenCount int `json:"open_count"` // AcknowledgedCount ist die Zahl zur Kenntnis genommener Meldungen. AcknowledgedCount int `json:"acknowledged_count"` // CriticalCount ist die Zahl unerledigter kritischer Meldungen. CriticalCount int `json:"critical_count"` // HighCount ist die Zahl unerledigter ernster Meldungen. HighCount int `json:"high_count"` // ResolvedLastDay ist die Zahl der in 24 Stunden erledigten Meldungen. ResolvedLastDay int `json:"resolved_last_day"` } // Summary erhebt die Meldungslage. func (store *Store) Summary(summaryContext context.Context) (*AlertSummary, error) { const summaryStatement = ` SELECT count(*) FILTER (WHERE status = 'open'), count(*) FILTER (WHERE status = 'acknowledged'), count(*) FILTER (WHERE status IN ('open', 'acknowledged') AND severity = 'critical'), count(*) FILTER (WHERE status IN ('open', 'acknowledged') AND severity = 'high'), count(*) FILTER (WHERE status = 'resolved' AND resolved_at > now() - interval '24 hours') FROM alerts` var summary AlertSummary if scanError := store.connectionPool.QueryRow(summaryContext, summaryStatement).Scan( &summary.OpenCount, &summary.AcknowledgedCount, &summary.CriticalCount, &summary.HighCount, &summary.ResolvedLastDay); scanError != nil { return nil, fmt.Errorf("die meldungslage konnte nicht erhoben werden: %w", scanError) } return &summary, nil } // PruneResolvedAlerts entfernt lange erledigte Meldungen. func (store *Store) PruneResolvedAlerts(pruneContext context.Context, retentionPeriod time.Duration) (int64, error) { const deleteStatement = ` DELETE FROM alerts WHERE status = 'resolved' AND resolved_at < now() - $1::interval` commandTag, execError := store.connectionPool.Exec(pruneContext, deleteStatement, retentionPeriod.String()) if execError != nil { return 0, fmt.Errorf("erledigte meldungen konnten nicht entfernt werden: %w", execError) } return commandTag.RowsAffected(), nil }