Enterprise-Backup-, Recovery-, Verification-, Security- und Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme. Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes geht in keine Bewertung als "gut" ein. Umfang (Phasen 0-23): - Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll, Katalogaufbau allein aus den Manifesten — ohne Datenbank - Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck - Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell) - Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive - Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit - Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center, Ransomware-Heuristik (meldet, handelt nie) - Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing - Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository - Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64 Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs. Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md und docs/release-candidate.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
558 lines
19 KiB
Go
558 lines
19 KiB
Go
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
|
|
}
|