syncova-backup/migrations/000009_alerts.up.sql
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
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>
2026-08-17 09:10:54 +02:00

210 lines
9.5 KiB
SQL

-- Meldungen und Benachrichtigungen (Phase 14).
--
-- Das Meldungswesen hat einen Feind, und es ist nicht der fehlende Alarm: Es ist
-- der Alarm, den niemand mehr liest. Ein System, das jede Minute dieselbe
-- Meldung erzeugt, wird nach drei Tagen weggeklickt — und dann fehlt die eine,
-- auf die es ankam.
--
-- Das Schema ist deshalb um zwei Eigenschaften herum gebaut:
--
-- 1. **Eine Ursache, eine Meldung.** Der Fingerabdruck macht die Doppelung
-- unmoeglich, nicht die Anwendungslogik.
-- 2. **Meldungen loesen sich selbst auf.** Verschwindet die Ursache, schliesst
-- sich die Meldung. Was von Hand geschlossen werden muss, sammelt sich an.
-- ---------------------------------------------------------------------------
-- Meldungen
-- ---------------------------------------------------------------------------
CREATE TABLE alerts (
-- id ist der oeffentliche Bezeichner.
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- rule_name benennt die ausloesende Regel.
rule_name TEXT NOT NULL,
-- severity ist der Schweregrad.
severity TEXT NOT NULL,
-- status ist der Bearbeitungszustand.
status TEXT NOT NULL DEFAULT 'open',
-- fingerprint kennzeichnet die Ursache eindeutig.
--
-- Er entsteht aus Regel und betroffenem Gegenstand, nicht aus dem Zeitpunkt.
-- Genau deshalb erzeugt derselbe Fehlschlag beim zweiten Durchgang keine
-- zweite Meldung, sondern aktualisiert die erste.
fingerprint TEXT NOT NULL,
-- title ist die Ueberschrift.
title TEXT NOT NULL,
-- message erklaert den Befund und die naechste Handlung.
--
-- Eine Meldung ohne Handlungsanweisung ist eine Beunruhigung. Der Text nennt
-- deshalb immer, was zu tun ist.
message TEXT NOT NULL,
-- entity_type benennt die Art des betroffenen Gegenstands.
entity_type TEXT,
-- entity_id ist der betroffene Gegenstand.
entity_id UUID,
-- entity_name ist sein sprechender Name.
--
-- Fest gespeichert und nicht ueber einen Fremdschluessel gelesen: Die
-- Meldung muss auch dann noch lesbar sein, wenn der Auftrag geloescht wurde.
entity_name TEXT,
-- details traegt die Messwerte des Befunds.
details JSONB,
-- occurrence_count zaehlt, wie oft die Ursache seither auftrat.
--
-- Statt zehn gleicher Meldungen gibt es eine mit einem Zaehler. Die Zahl ist
-- die eigentliche Auskunft: Einmal ist ein Zwischenfall, zwanzigmal ein
-- Zustand.
occurrence_count INTEGER NOT NULL DEFAULT 1,
-- first_seen_at ist das erste Auftreten in UTC.
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- last_seen_at ist das letzte Auftreten in UTC.
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- acknowledged_at ist der Zeitpunkt der Kenntnisnahme in UTC.
acknowledged_at TIMESTAMPTZ,
-- acknowledged_by benennt den Bestaetigenden.
acknowledged_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- acknowledgement_note haelt eine Bemerkung des Bestaetigenden fest.
acknowledgement_note TEXT,
-- resolved_at ist der Zeitpunkt der Aufloesung in UTC.
resolved_at TIMESTAMPTZ,
-- resolved_by benennt den Aufloesenden; NULL bei automatischer Aufloesung.
--
-- Der Unterschied ist wichtig: Eine von selbst verschwundene Ursache ist
-- etwas anderes als eine weggeklickte Meldung.
resolved_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- resolution_note begruendet die Aufloesung.
resolution_note TEXT,
-- created_at ist der Anlagezeitpunkt in UTC.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT alerts_severity_valid
CHECK (severity IN ('information', 'warning', 'high', 'critical')),
CONSTRAINT alerts_status_valid
CHECK (status IN ('open', 'acknowledged', 'resolved')),
-- Eine aufgeloeste Meldung braucht einen Zeitpunkt, sonst fehlt sie in jeder
-- Auswertung.
CONSTRAINT alerts_resolved_has_time CHECK (
status <> 'resolved' OR resolved_at IS NOT NULL
),
-- Eine bestaetigte Meldung braucht einen Bestaetigenden. „Jemand hat es
-- gesehen" ohne Namen ist keine Kenntnisnahme.
CONSTRAINT alerts_acknowledged_has_actor CHECK (
acknowledged_at IS NULL OR acknowledged_by IS NOT NULL
),
CONSTRAINT alerts_occurrence_positive CHECK (occurrence_count > 0)
);
-- **Der wichtigste Index dieser Migration.**
--
-- Er macht eine zweite offene Meldung zur selben Ursache unmoeglich. Ohne ihn
-- muesste die Anwendung vor jedem Schreiben nachsehen — und zwischen Nachsehen
-- und Schreiben passt ein zweiter Control-Server.
CREATE UNIQUE INDEX alerts_single_open_idx
ON alerts (fingerprint)
WHERE status IN ('open', 'acknowledged');
CREATE INDEX alerts_status_severity_idx ON alerts (status, severity, last_seen_at DESC);
CREATE INDEX alerts_entity_idx ON alerts (entity_type, entity_id);
CREATE INDEX alerts_created_idx ON alerts (created_at DESC);
-- ---------------------------------------------------------------------------
-- Benachrichtigungskanaele
-- ---------------------------------------------------------------------------
CREATE TABLE notification_channels (
-- id ist der oeffentliche Bezeichner.
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- name ist die sprechende Bezeichnung.
name TEXT NOT NULL UNIQUE,
-- channel_type ist die Art der Zustellung.
channel_type TEXT NOT NULL,
-- enabled meldet einen aktiven Kanal.
enabled BOOLEAN NOT NULL DEFAULT true,
-- minimum_severity ist der niedrigste zugestellte Schweregrad.
--
-- Ohne diese Grenze bekaeme der Bereitschaftsdienst nachts jede Information.
-- Nach einer Woche schaltet er die Benachrichtigungen ab, und dann kommt
-- auch die kritische nicht mehr an.
minimum_severity TEXT NOT NULL DEFAULT 'high',
-- configuration traegt die Zustelldaten.
--
-- Zugangsdaten stehen **nicht** hier, sondern verschluesselt in
-- credentials_ciphertext (PROMPT.md §140: keine Secrets im Klartext).
configuration JSONB NOT NULL,
-- credentials_ciphertext ist das verschluesselte Zugangsgeheimnis.
credentials_ciphertext BYTEA,
-- credentials_key_version benennt den verwendeten Schluessel.
credentials_key_version TEXT,
-- last_delivery_at ist der Zeitpunkt der letzten Zustellung in UTC.
last_delivery_at TIMESTAMPTZ,
-- last_delivery_error ist der Grund des letzten Fehlschlags.
--
-- Ein Kanal, der seit Wochen nichts zustellt, ist genauso schlimm wie eine
-- fehlende Meldung — und ohne dieses Feld faellt es niemandem auf.
last_delivery_error TEXT,
-- consecutive_failures zaehlt die Fehlschlaege in Folge.
consecutive_failures INTEGER NOT NULL DEFAULT 0,
-- created_by benennt den Anlegenden.
created_by UUID REFERENCES users(id) ON DELETE SET NULL,
-- created_at ist der Anlagezeitpunkt in UTC.
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- updated_at ist der Zeitpunkt der letzten Aenderung in UTC.
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT notification_channels_type_valid
CHECK (channel_type IN ('email', 'webhook')),
CONSTRAINT notification_channels_severity_valid
CHECK (minimum_severity IN ('information', 'warning', 'high', 'critical')),
CONSTRAINT notification_channels_failures_not_negative
CHECK (consecutive_failures >= 0),
-- Ein verschluesseltes Geheimnis ohne Schluesselversion liesse sich nicht
-- mehr entschluesseln.
CONSTRAINT notification_channels_credentials_have_version CHECK (
credentials_ciphertext IS NULL OR credentials_key_version IS NOT NULL
)
);
CREATE INDEX notification_channels_enabled_idx ON notification_channels (enabled)
WHERE enabled = true;
-- ---------------------------------------------------------------------------
-- Zustellungen
-- ---------------------------------------------------------------------------
-- Jede Zustellung wird festgehalten — auch die gescheiterte. Die Frage „ist die
-- Meldung von gestern Nacht rausgegangen?" muss beantwortbar sein, und zwar
-- ohne im Protokoll zu suchen.
CREATE TABLE notification_deliveries (
-- id ist der fortlaufende Bezeichner.
id BIGSERIAL PRIMARY KEY,
-- alert_id ist die zugestellte Meldung.
alert_id UUID NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
-- channel_id ist der verwendete Kanal.
channel_id UUID NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
-- status ist der Ausgang der Zustellung.
status TEXT NOT NULL,
-- attempt_number ist die Nummer des Versuchs.
attempt_number INTEGER NOT NULL DEFAULT 1,
-- error_message ist der Grund eines Fehlschlags.
error_message TEXT,
-- delivered_at ist der Zeitpunkt in UTC.
delivered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT notification_deliveries_status_valid
CHECK (status IN ('sent', 'failed', 'skipped')),
-- Ein Fehlschlag ohne Grund liesse sich nicht beheben.
CONSTRAINT notification_deliveries_failure_has_reason CHECK (
status <> 'failed' OR error_message IS NOT NULL
)
);
CREATE INDEX notification_deliveries_alert_idx ON notification_deliveries (alert_id);
CREATE INDEX notification_deliveries_channel_idx ON notification_deliveries (channel_id, delivered_at DESC);
-- Dieselbe Meldung wird ueber denselben Kanal nur einmal erfolgreich zugestellt.
-- Ohne diese Sperre erzeugte jeder Durchgang der Auswertung eine neue Mail zur
-- selben, weiterhin offenen Meldung.
CREATE UNIQUE INDEX notification_deliveries_once_idx
ON notification_deliveries (alert_id, channel_id)
WHERE status = 'sent';