Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da80643564 | ||
|
|
814a7fda0a |
@@ -0,0 +1,43 @@
|
|||||||
|
// metrics-devserver stellt den OPS-03-Metrics-Aggregator (internal/metrics)
|
||||||
|
// unter /metrics bereit, damit ein echter Prometheus-Scrape-Vorgang gegen
|
||||||
|
// den Core-Dienst geprueft werden kann (Pruefung 3). Getrennt von cmd/core
|
||||||
|
// aus demselben Grund wie die anderen *-devserver.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||||
|
"gitea.perlbach24.de/scripte/nexarch/internal/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
dsn := os.Getenv("NEXARCH_REGISTRY_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt")
|
||||||
|
}
|
||||||
|
addr := os.Getenv("NEXARCH_METRICS_LISTEN_ADDR")
|
||||||
|
if addr == "" {
|
||||||
|
addr = ":8085"
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := db.Connect(ctx, dsn)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("db: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
sourceStore := metrics.NewSourceStore(pool)
|
||||||
|
agg := metrics.NewAggregator(metrics.NewCoreRegistry(), sourceStore.Provide)
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/metrics", agg.Handler())
|
||||||
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
|
||||||
|
log.Printf("metrics-devserver listening on %s", addr)
|
||||||
|
log.Fatal(http.ListenAndServe(addr, mux))
|
||||||
|
}
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
# NEXARCH Incident-Response-Plan (inkl. DSGVO-Meldefristen)
|
|
||||||
|
|
||||||
**Kachel:** Core OPS-04 | **Stand:** 2026-08-28 | **Geltungsbereich:** NEXARCH Core und alle Fachmodule (DMS, Mail, Archive, Workflow, AI, Connect)
|
|
||||||
|
|
||||||
> Dieses Dokument ist eine **auszufüllende Vorlage**. Felder in eckigen Klammern
|
|
||||||
> (`[AUSZUFÜLLEN: ...]`) müssen vom jeweiligen Betreiber (SaaS-Anbieter oder
|
|
||||||
> On-Premise-Kunde, siehe `SAAS-BETRIEBSMODELL.md`) mit echten Namen,
|
|
||||||
> Telefonnummern und E-Mail-Adressen befüllt werden, bevor der Plan
|
|
||||||
> betrieblich wirksam ist. Ohne befüllte Kontaktliste (Abschnitt 7) ist
|
|
||||||
> dieser Plan nicht einsatzbereit — siehe Prüfung 3.
|
|
||||||
|
|
||||||
## 1. Zweck
|
|
||||||
|
|
||||||
Ablaufplan für Sicherheitsvorfälle (Datenleck, kompromittiertes
|
|
||||||
Service-Credential, kompromittierter Core-Signaturschlüssel, unbefugter
|
|
||||||
Zugriff, Ransomware, Ausfall mit Datenverlust): Erkennung, Klassifizierung,
|
|
||||||
Eskalation, Sofortmaßnahmen, DSGVO-Meldefristen, Kommunikation,
|
|
||||||
Nachbereitung. Ergänzt `SICHERHEITSKONZEPT.md` (dort: präventive
|
|
||||||
Architekturentscheidungen) um den reaktiven Ablauf im Ernstfall.
|
|
||||||
|
|
||||||
## 2. Erkennung — technische Quellen
|
|
||||||
|
|
||||||
Ein Vorfall wird über eine oder mehrere dieser Quellen bemerkt:
|
|
||||||
|
|
||||||
| Quelle | Was sie zeigt | Code-Anknüpfung |
|
|
||||||
|---|---|---|
|
|
||||||
| Zentrale Statusseite | Ausfall/Fehlverhalten eines Moduls | Core `OPS-02` |
|
|
||||||
| Metrics-Aggregation | Anomale Kennzahlen (z.B. Anstieg von 401/403, ungewöhnliche Zugriffszahlen) | Core `OPS-03` |
|
|
||||||
| Health-/Readiness-Endpunkte | Abhängigkeitsausfall (DB, Queue) | Core `OPS-01` (`internal/health`) |
|
|
||||||
| **Audit-Log** | Wer hat wann was getan — die primäre forensische Quelle für JEDEN Vorfall mit Personenbezug oder Rechteänderung | Core `AUD-01` (`internal/audit/audit.go`, `Log.Record`), Export/Filter über `AUD-03` (`internal/audit/export.go`, `StreamCSV`/`StreamJSON` nach Zeitraum/Akteur/Aktion/Tenant) |
|
|
||||||
| Aufbewahrungs-/Löschprotokoll | Ungewöhnliche oder unautorisierte Löschvorgänge | Archive `RET-03`/`CMP-06` (geplant, noch nicht gebaut) |
|
|
||||||
| Meldung durch Dritte | Kunde, Mitarbeiter, externer Sicherheitsforscher meldet einen Verdacht | — |
|
|
||||||
|
|
||||||
Das Audit-Log (`AUD-01`) ist laut `SICHERHEITSKONZEPT.md` **append-only**
|
|
||||||
(`AUD-02`, DB-Trigger-Schutz gegen UPDATE/DELETE) — es ist damit die
|
|
||||||
vertrauenswürdigste Quelle für die Rekonstruktion eines Vorfalls, weil ein
|
|
||||||
Angreifer es nicht nachträglich manipulieren kann.
|
|
||||||
|
|
||||||
## 3. Klassifizierung
|
|
||||||
|
|
||||||
| Schweregrad | Beispiel | Meldepflichtig nach Art. 33 DSGVO? |
|
|
||||||
|---|---|---|
|
|
||||||
| **Kritisch** | Personenbezogene Daten mehrerer Mandanten abgeflossen; Master-Key (`API-10`) kompromittiert | Ja, mit hoher Wahrscheinlichkeit |
|
|
||||||
| **Hoch** | Ein Mandant betroffen, personenbezogene Daten eingesehen/exfiltriert | Ja, sofern Risiko für Betroffene nicht auszuschließen ist |
|
|
||||||
| **Mittel** | Kompromittiertes Service-Credential (`API-02`) ohne nachweisbaren Datenzugriff | Einzelfallprüfung durch Datenschutzbeauftragten |
|
|
||||||
| **Niedrig** | Fehlkonfiguration ohne Datenzugriff, rechtzeitig erkannt | Nein, aber intern dokumentieren |
|
|
||||||
|
|
||||||
Die Einstufung "meldepflichtig" ist IMMER eine rechtliche Bewertung durch
|
|
||||||
den Datenschutzbeauftragten (Rolle, siehe Abschnitt 7) — diese Tabelle ist
|
|
||||||
eine Ersteinschätzungshilfe für die technische Eskalation, kein Ersatz für
|
|
||||||
die rechtliche Prüfung.
|
|
||||||
|
|
||||||
## 4. Eskalationskette (Rollen)
|
|
||||||
|
|
||||||
| Rolle | Verantwortlich für | Wird informiert |
|
|
||||||
|---|---|---|
|
|
||||||
| **Incident Commander** | Koordiniert die gesamte Reaktion, trifft operative Entscheidungen | Sofort bei Erkennung (Schweregrad Mittel/Hoch/Kritisch) |
|
|
||||||
| **Technischer Verantwortlicher** | Eindämmung, Beweissicherung (Audit-Log-Export), technische Ursachenanalyse | Sofort bei Erkennung |
|
|
||||||
| **Datenschutzbeauftragter (DSB)** | Rechtliche Einstufung, DSGVO-Meldung an Aufsichtsbehörde, Betroffenen-Benachrichtigung (Art. 34) | Innerhalb 1 Stunde ab Schweregrad Mittel |
|
|
||||||
| **Geschäftsführung/Betreiber** | Externe Kommunikation, Kundenbenachrichtigung, AVV-Pflichten (siehe `SAAS-BETRIEBSMODELL.md`) | Innerhalb 4 Stunden ab Schweregrad Hoch/Kritisch |
|
|
||||||
|
|
||||||
Jede dieser Rollen benötigt Stellvertretung (Urlaub/Krankheit) — siehe
|
|
||||||
Kontaktliste Abschnitt 7.
|
|
||||||
|
|
||||||
## 5. Sofortmaßnahmen (Eindämmung)
|
|
||||||
|
|
||||||
1. Betroffene Zugänge/Credentials sperren (Service-Credential-Widerruf,
|
|
||||||
`API-02`; Session-Widerruf, `IAM-12`; bei kompromittiertem
|
|
||||||
Core-Signaturschlüssel: sofortige Schlüsselrotation ohne Ausfallzeit,
|
|
||||||
`API-05`/`API-09`/`API-10` — alle drei unterstützen rotationsfähige
|
|
||||||
Schlüssel/Zertifikate ohne Downtime).
|
|
||||||
2. Beweissicherung: Audit-Log-Export für den betroffenen Zeitraum/Tenant/
|
|
||||||
Akteur **vor** jeder Aufräumaktion (`internal/audit.Log.StreamCSV`/
|
|
||||||
`StreamJSON`, `AUD-03`) — unveränderlich, daher jederzeit nachträglich
|
|
||||||
exportierbar.
|
|
||||||
3. Betroffenen Mandanten identifizieren (Tenant-Registry, `TEN-01`) — dank
|
|
||||||
physischer Modell-C-Trennung ist ein Vorfall bei einem Mandanten
|
|
||||||
technisch strukturell auf diesen einen begrenzt (siehe
|
|
||||||
`SICHERHEITSKONZEPT.md` Abschnitt zu TEN-01).
|
|
||||||
4. Zeitpunkt der Kenntniserlangung dokumentieren (Startpunkt der
|
|
||||||
72-Stunden-Frist, siehe Abschnitt 6).
|
|
||||||
|
|
||||||
## 6. DSGVO-Meldefrist-Prozess (Art. 33/34 DSGVO)
|
|
||||||
|
|
||||||
1. **Start der Frist**: Zeitpunkt, an dem der Betreiber (nicht der
|
|
||||||
Entdecker im technischen Team) hinreichend sichere Kenntnis vom Vorfall
|
|
||||||
hat — dokumentiert vom Incident Commander.
|
|
||||||
2. **Verantwortlich für die Meldung**: Datenschutzbeauftragter.
|
|
||||||
3. **Frist**: 72 Stunden ab Kenntniserlangung, an die zuständige
|
|
||||||
Aufsichtsbehörde — auch wenn die Untersuchung noch nicht abgeschlossen
|
|
||||||
ist (Art. 33 Abs. 4 erlaubt eine gestaffelte Meldung).
|
|
||||||
4. **Inhalt der Meldung** (Art. 33 Abs. 3): Art der Verletzung, betroffene
|
|
||||||
Kategorien/ungefähre Anzahl Betroffener und Datensätze, Kontakt des DSB,
|
|
||||||
wahrscheinliche Folgen, ergriffene/vorgeschlagene Maßnahmen.
|
|
||||||
5. **Betroffenen-Benachrichtigung** (Art. 34): zusätzlich erforderlich, wenn
|
|
||||||
ein VORAUSSICHTLICH HOHES Risiko für die Rechte der betroffenen Personen
|
|
||||||
besteht — Entscheidung durch DSB, unverzüglich.
|
|
||||||
6. **Keine Meldung nötig**: nur wenn nachweislich kein Risiko für
|
|
||||||
Betroffene besteht (z.B. Daten waren durch API-10-Envelope-Encryption
|
|
||||||
wirksam verschlüsselt und der Schlüssel selbst nicht kompromittiert) —
|
|
||||||
diese Einschätzung UND ihre Begründung wird dennoch dokumentiert
|
|
||||||
(Art. 33 Abs. 5: Dokumentationspflicht besteht unabhängig von der
|
|
||||||
Meldepflicht).
|
|
||||||
7. **Vertragliche Ebene**: bei SaaS-/Private-Cloud-Betrieb regelt der AVV
|
|
||||||
(Art. 28 DSGVO) zusätzlich, in welcher (kürzeren) Frist der
|
|
||||||
Auftragsverarbeiter den Verantwortlichen (Kunde) informieren muss, BEVOR
|
|
||||||
die 72-Stunden-Frist gegenüber der Behörde zu laufen beginnt — siehe
|
|
||||||
`SAAS-BETRIEBSMODELL.md`.
|
|
||||||
|
|
||||||
## 7. Kontaktliste (auszufüllen vom Betreiber)
|
|
||||||
|
|
||||||
| Rolle | Name | Telefon | E-Mail | Stellvertretung |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| Incident Commander | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
|
|
||||||
| Technischer Verantwortlicher | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
|
|
||||||
| Datenschutzbeauftragter | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
|
|
||||||
| Geschäftsführung/Betreiber | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] | [AUSZUFÜLLEN] |
|
|
||||||
| Zuständige Aufsichtsbehörde | [AUSZUFÜLLEN, abhängig vom Sitz des Betreibers] | — | [AUSZUFÜLLEN] | — |
|
|
||||||
|
|
||||||
**Zuletzt bestätigt (Erreichbarkeitstest durchgeführt am):** [AUSZUFÜLLEN —
|
|
||||||
noch nicht durchgeführt, siehe Prüfung 3]
|
|
||||||
|
|
||||||
## 8. Kommunikation
|
|
||||||
|
|
||||||
- **Intern**: Eskalationskette (Abschnitt 4) zuerst, keine Information nach
|
|
||||||
außen vor Freigabe durch Geschäftsführung.
|
|
||||||
- **Extern (Kunden)**: bei SaaS-/Private-Cloud-Betrieb gemäß AVV-Frist,
|
|
||||||
spätestens mit/vor der Behördenmeldung.
|
|
||||||
- **Extern (Betroffene)**: nur bei hohem Risiko, siehe Abschnitt 6.5, Text
|
|
||||||
in verständlicher, nicht-technischer Sprache.
|
|
||||||
- **Presse/Öffentlichkeit**: ausschließlich durch Geschäftsführung.
|
|
||||||
|
|
||||||
## 9. Nachbereitung
|
|
||||||
|
|
||||||
1. Lessons-Learned-Sitzung mit allen beteiligten Rollen (spätestens 2
|
|
||||||
Wochen nach Abschluss).
|
|
||||||
2. Vollständige Audit-Log-Auswertung des Vorfallszeitraums archivieren
|
|
||||||
(separat vom laufenden Audit-Log, als Vorfallsakte).
|
|
||||||
3. Prüfen, ob eine Schlüssel-Notfallrotation nötig ist/war (`API-05` JWT-
|
|
||||||
Signaturschlüssel, `API-09` mTLS-Zertifikate, `API-10` Master-/
|
|
||||||
Tenant-KEK) — alle drei sind so gebaut, dass Rotation ohne Ausfallzeit
|
|
||||||
möglich ist.
|
|
||||||
4. Diesen Plan aktualisieren, wenn die Übung/der echte Vorfall eine Lücke
|
|
||||||
aufgezeigt hat.
|
|
||||||
|
|
||||||
## 10. Durchgeführte Prüfungen
|
|
||||||
|
|
||||||
### Prüfung 1 — Simulierte Vorfallsübung (Tabletop), durchgeführt 2026-08-28
|
|
||||||
|
|
||||||
**Szenario**: Ein kompromittiertes Service-Credential des DMS-Moduls wird
|
|
||||||
festgestellt (ungewöhnliche Anfragemuster in der Metrics-Aggregation,
|
|
||||||
`OPS-03`).
|
|
||||||
|
|
||||||
**Durchgespielter Ablauf**:
|
|
||||||
1. *Erkennung*: Anomalie fällt in der Metrics-Aggregation auf (Abschnitt 2)
|
|
||||||
→ Technischer Verantwortlicher prüft das Audit-Log für den betroffenen
|
|
||||||
Zeitraum (`AUD-03`-Export, gefiltert nach `actor` = Service-Credential
|
|
||||||
des DMS-Moduls).
|
|
||||||
2. *Klassifizierung*: Kompromittiertes Service-Credential ohne
|
|
||||||
nachgewiesenen Datenzugriff → Schweregrad **Mittel** (Abschnitt 3).
|
|
||||||
3. *Eskalation*: Incident Commander + Technischer Verantwortlicher sofort,
|
|
||||||
DSB innerhalb 1 Stunde (Abschnitt 4).
|
|
||||||
4. *Sofortmaßnahme*: Service-Credential des DMS-Moduls über `API-02`
|
|
||||||
widerrufen und neu provisioniert; betroffene Tenant-Verbindungen
|
|
||||||
(`TEN-01`) identifiziert.
|
|
||||||
5. *Beweissicherung*: Vollständiger Audit-Log-Export für den Zeitraum vor
|
|
||||||
dem Widerruf (`AUD-03`).
|
|
||||||
6. *DSGVO-Bewertung*: DSB prüft anhand des Audit-Log-Exports, ob
|
|
||||||
tatsächlich personenbezogene Daten abgerufen wurden. Ergebnis im
|
|
||||||
simulierten Szenario: kein nachweisbarer Datenzugriff über die normale
|
|
||||||
Nutzung des Moduls hinaus → keine Meldepflicht, aber Dokumentation
|
|
||||||
gemäß Art. 33 Abs. 5.
|
|
||||||
7. *Nachbereitung*: Ursache (wie kam das Credential abhanden) klären,
|
|
||||||
Rotationsintervall für Service-Credentials als offenen Punkt vermerkt.
|
|
||||||
|
|
||||||
**Ergebnis**: Der Ablauf war anhand des Dokuments ohne Lücke durchspielbar
|
|
||||||
— jeder Schritt hatte eine konkrete technische Anknüpfung. **PASS.**
|
|
||||||
|
|
||||||
### Prüfung 2 — Meldefrist-Prozess auf Vollständigkeit geprüft, 2026-08-28
|
|
||||||
|
|
||||||
Abgleich von Abschnitt 6 gegen Art. 33/34 DSGVO, Punkt für Punkt:
|
|
||||||
|
|
||||||
| Anforderung (Art. 33/34) | Im Plan enthalten? |
|
|
||||||
|---|---|
|
|
||||||
| Fristbeginn = Kenntniserlangung, nicht Entdeckung durch Einzelperson | Ja (6.1) |
|
|
||||||
| 72-Stunden-Frist an Aufsichtsbehörde | Ja (6.3) |
|
|
||||||
| Gestaffelte Meldung erlaubt | Ja (6.3) |
|
|
||||||
| Pflichtinhalt der Meldung | Ja (6.4) |
|
|
||||||
| Betroffenen-Benachrichtigung bei hohem Risiko | Ja (6.5) |
|
|
||||||
| Dokumentationspflicht auch ohne Meldepflicht | Ja (6.6) |
|
|
||||||
| Verantwortliche Rolle benannt | Ja (6.2, DSB) |
|
|
||||||
| Vertragliche AVV-Frist ggü. Kunde vor Behördenfrist | Ja (6.7) |
|
|
||||||
|
|
||||||
**Ergebnis: vollständig. PASS.**
|
|
||||||
|
|
||||||
### Prüfung 3 — Kontaktliste aktuell und erreichbar bestätigt
|
|
||||||
|
|
||||||
**Status: OFFEN.** Abschnitt 7 enthält ausschließlich Platzhalter
|
|
||||||
(`[AUSZUFÜLLEN]`), da dieses Projekt noch keine reale Betreiber-Organisation
|
|
||||||
mit benannten Personen/Telefonnummern hat. Diese Prüfung kann nicht durch
|
|
||||||
Code oder Dokumentation allein bestanden werden — sie erfordert, dass der
|
|
||||||
tatsächliche Betreiber Abschnitt 7 mit echten Kontakten befüllt UND einen
|
|
||||||
Erreichbarkeitstest durchführt (z.B. Testanruf/Test-E-Mail an jede Rolle).
|
|
||||||
**Bleibt nicht durchgeführt, bis diese Angaben vorliegen — wird hier
|
|
||||||
transparent als offen dokumentiert statt fälschlich als erledigt markiert.**
|
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import "github.com/prometheus/client_golang/prometheus"
|
||||||
|
|
||||||
|
// NewCoreRegistry liefert das Prometheus-Registry fuer die EIGENEN
|
||||||
|
// Kennzahlen des Core-Dienstes (Akzeptanzkriterium 1) — alle Namen tragen
|
||||||
|
// das Praefix "nexarch_core_" gemaess der im Paketkommentar dokumentierten
|
||||||
|
// Namenskonvention (Akzeptanzkriterium 3). Ein eigenes Registry statt des
|
||||||
|
// globalen DefaultRegisterer, damit Tests unabhaengig voneinander sind.
|
||||||
|
func NewCoreRegistry() *prometheus.Registry {
|
||||||
|
reg := prometheus.NewRegistry()
|
||||||
|
reg.MustRegister(
|
||||||
|
prometheus.NewGaugeFunc(prometheus.GaugeOpts{
|
||||||
|
Name: "nexarch_core_up",
|
||||||
|
Help: "1, solange der Core-Dienst laeuft und Metriken liefern kann.",
|
||||||
|
}, func() float64 { return 1 }),
|
||||||
|
)
|
||||||
|
return reg
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Package metrics implementiert Core OPS-03: einen zentralen /metrics-
|
||||||
|
// Endpunkt im Prometheus-Textformat, der Kennzahlen des Core-Dienstes UND
|
||||||
|
// aggregierte Kennzahlen aller registrierten Module bereitstellt — offenes
|
||||||
|
// Pull-Modell nach Prometheus-Vorbild, kein proprietaerer Push-Mechanismus.
|
||||||
|
//
|
||||||
|
// Namenskonvention (Akzeptanzkriterium 3, modulübergreifend konsistent):
|
||||||
|
//
|
||||||
|
// nexarch_core_<name> — Kennzahlen des Core-Dienstes selbst
|
||||||
|
// nexarch_module_<modul>_<name> — von einem Modul gescrapte Kennzahl
|
||||||
|
// <name>, umbenannt mit dem
|
||||||
|
// Modulnamen als Praefix
|
||||||
|
//
|
||||||
|
// Ein Modul liefert seine eigenen Kennzahlen unter EIGENEM Namen (z.B.
|
||||||
|
// "requests_total") unter seinem eigenen /metrics-Endpunkt — dieses Paket
|
||||||
|
// benennt sie beim Einsammeln konsistent um, damit im aggregierten Core-
|
||||||
|
// Endpunkt niemals zwei Module denselben Metrik-Namen kollidieren lassen.
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
dto "github.com/prometheus/client_model/go"
|
||||||
|
"github.com/prometheus/common/expfmt"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// init erzwingt das klassische Prometheus-Namensschema (a-z, A-Z, 0-9, _)
|
||||||
|
// fuer die Namensvalidierung von expfmt/model — ohne diese explizite
|
||||||
|
// Festlegung liefert die Bibliothek "Invalid name validation scheme
|
||||||
|
// requested: unset" beim Parsen/Kodieren, da sie den globalen Default in
|
||||||
|
// dieser Version nicht mehr implizit setzt.
|
||||||
|
func init() {
|
||||||
|
model.NameValidationScheme = model.LegacyValidation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source ist EIN registriertes Modul mit seinem eigenen /metrics-Endpunkt
|
||||||
|
// (siehe internal/health fuer das analoge Muster bei Readiness-Checks).
|
||||||
|
type Source struct {
|
||||||
|
ModuleName string
|
||||||
|
MetricsURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceProvider liefert die aktuell registrierten Module — typischerweise
|
||||||
|
// rueckgebunden an internal/moduleregistry.Registry.List (API-02) ueber
|
||||||
|
// einen kleinen Adapter im aufrufenden Code, damit dieses Paket
|
||||||
|
// internal/moduleregistry nicht direkt importieren muss (Kein Umbau
|
||||||
|
// angrenzender Bereiche). Ein NEU registriertes Modul erscheint automatisch
|
||||||
|
// beim naechsten Aufruf von Aggregator.Handler, OHNE Codeaenderung an diesem
|
||||||
|
// Paket (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
type SourceProvider func(ctx context.Context) ([]Source, error)
|
||||||
|
|
||||||
|
// FetchTimeout begrenzt, wie lange EIN Modul-Scrape maximal dauern darf —
|
||||||
|
// ein haengendes Modul darf den gesamten Aggregations-Request nicht
|
||||||
|
// verzoegern (Pruefung 1: Antwort unter Last innerhalb definierter Zeit).
|
||||||
|
const FetchTimeout = 2 * time.Second
|
||||||
|
|
||||||
|
// Aggregator sammelt Core-eigene Metriken (coreGatherer) und die Metriken
|
||||||
|
// aller ueber sourceProvider gemeldeten Module in EINER Antwort ein.
|
||||||
|
type Aggregator struct {
|
||||||
|
coreGatherer prometheus.Gatherer
|
||||||
|
sourceProvider SourceProvider
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewAggregator(coreGatherer prometheus.Gatherer, sourceProvider SourceProvider) *Aggregator {
|
||||||
|
return &Aggregator{
|
||||||
|
coreGatherer: coreGatherer,
|
||||||
|
sourceProvider: sourceProvider,
|
||||||
|
client: &http.Client{Timeout: FetchTimeout},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gather implementiert prometheus.Gatherer: liefert Core-Metriken PLUS alle
|
||||||
|
// erreichbaren Modul-Metriken (umbenannt gemaess Namenskonvention) in einer
|
||||||
|
// gemeinsamen Liste von MetricFamilies.
|
||||||
|
func (a *Aggregator) Gather(ctx context.Context) ([]*dto.MetricFamily, error) {
|
||||||
|
families, err := a.coreGatherer.Gather()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("core-metriken einsammeln: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := a.sourceProvider(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("modul-quellen ermitteln: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module werden NEBENLAEUFIG gescrapt (dasselbe Muster wie
|
||||||
|
// internal/health.Registry.CheckAll) — ein langsames/nicht erreichbares
|
||||||
|
// Modul haelt weder andere Module noch den Gesamt-Request auf.
|
||||||
|
type fetchResult struct {
|
||||||
|
families []*dto.MetricFamily
|
||||||
|
}
|
||||||
|
resultCh := make(chan fetchResult, len(sources))
|
||||||
|
for _, src := range sources {
|
||||||
|
go func(src Source) {
|
||||||
|
fetchCtx, cancel := context.WithTimeout(ctx, FetchTimeout)
|
||||||
|
defer cancel()
|
||||||
|
mf, err := a.fetchAndRename(fetchCtx, src)
|
||||||
|
if err != nil {
|
||||||
|
resultCh <- fetchResult{} // Fehlerfall: einfach nichts beitragen, Aggregation laeuft weiter
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resultCh <- fetchResult{families: mf}
|
||||||
|
}(src)
|
||||||
|
}
|
||||||
|
for range sources {
|
||||||
|
r := <-resultCh
|
||||||
|
families = append(families, r.families...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return families, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchAndRename ruft die /metrics-URL eines Moduls ab, parst das
|
||||||
|
// Prometheus-Textformat und benennt jede Metrik gemaess der
|
||||||
|
// Namenskonvention um (Akzeptanzkriterium 2 + 3).
|
||||||
|
func (a *Aggregator) fetchAndRename(ctx context.Context, src Source) ([]*dto.MetricFamily, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, src.MetricsURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := a.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("modul %s: unerwarteter status %d", src.ModuleName, resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := expfmt.NewTextParser(model.LegacyValidation)
|
||||||
|
parsed, err := parser.TextToMetricFamilies(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("modul %s: metrik-text nicht parsebar: %w", src.ModuleName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]*dto.MetricFamily, 0, len(parsed))
|
||||||
|
for name, mf := range parsed {
|
||||||
|
renamed := fmt.Sprintf("nexarch_module_%s_%s", src.ModuleName, name)
|
||||||
|
mf.Name = &renamed
|
||||||
|
out = append(out, mf)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler liefert einen HTTP-Handler, der Gather aufruft und das Ergebnis im
|
||||||
|
// Prometheus-Textformat ausgibt (Akzeptanzkriterium 1).
|
||||||
|
func (a *Aggregator) Handler() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
families, err := a.Gather(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "metriken konnten nicht eingesammelt werden", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", string(expfmt.NewFormat(expfmt.TypeTextPlain)))
|
||||||
|
enc := expfmt.NewEncoder(w, expfmt.NewFormat(expfmt.TypeTextPlain))
|
||||||
|
for _, mf := range families {
|
||||||
|
if err := enc.Encode(mf); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/prometheus/common/expfmt"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fakeModuleServer(metricName string) *httptest.Server {
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4")
|
||||||
|
fmt.Fprintf(w, "# HELP %s ein test-zaehler\n# TYPE %s counter\n%s 42\n", metricName, metricName, metricName)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 1: Core liefert unter dem Handler valides
|
||||||
|
// Prometheus-Textformat mit den eigenen Kennzahlen.
|
||||||
|
func TestHandler_ServesCoreMetricsInPrometheusFormat(t *testing.T) {
|
||||||
|
agg := NewAggregator(NewCoreRegistry(), func(ctx context.Context) ([]Source, error) { return nil, nil })
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/metrics", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
agg.Handler()(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
parser := expfmt.NewTextParser(model.LegacyValidation)
|
||||||
|
families, err := parser.TextToMetricFamilies(strings.NewReader(rec.Body.String()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("antwort ist kein valides prometheus-textformat: %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := families["nexarch_core_up"]; !ok {
|
||||||
|
t.Fatalf("erwartet 'nexarch_core_up' unter den core-metriken, habe: %v", keysOf(families))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func keysOf[V any](m map[string]V) []string {
|
||||||
|
out := make([]string, 0, len(m))
|
||||||
|
for k := range m {
|
||||||
|
out = append(out, k)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 + Pruefung 2: ein NEU registriertes Modul erscheint
|
||||||
|
// in der Aggregation, OHNE dass dieses Paket oder der Aufrufer Code
|
||||||
|
// aendern muss — die Quelle kommt ausschliesslich aus sourceProvider.
|
||||||
|
func TestHandler_NewlyRegisteredModuleAppearsWithoutCodeChange(t *testing.T) {
|
||||||
|
moduleServer := fakeModuleServer("requests_total")
|
||||||
|
defer moduleServer.Close()
|
||||||
|
|
||||||
|
// Simuliert eine sich zur Laufzeit aendernde Modul-Liste (z.B. aus
|
||||||
|
// SourceStore.Provide) — zunaechst LEER, dann mit einem Eintrag.
|
||||||
|
var sources []Source
|
||||||
|
var mu sync.Mutex
|
||||||
|
provider := func(ctx context.Context) ([]Source, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
out := make([]Source, len(sources))
|
||||||
|
copy(out, sources)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
agg := NewAggregator(NewCoreRegistry(), provider)
|
||||||
|
|
||||||
|
// Vor der Registrierung: Modul-Metrik nicht vorhanden.
|
||||||
|
rec1 := httptest.NewRecorder()
|
||||||
|
agg.Handler()(rec1, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
if strings.Contains(rec1.Body.String(), "requests_total") {
|
||||||
|
t.Fatal("modul-metrik haette vor registrierung nicht erscheinen duerfen")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modul wird "registriert" (kein Code hier oder in metrics.go aendert sich).
|
||||||
|
mu.Lock()
|
||||||
|
sources = append(sources, Source{ModuleName: "dms", MetricsURL: moduleServer.URL})
|
||||||
|
mu.Unlock()
|
||||||
|
|
||||||
|
rec2 := httptest.NewRecorder()
|
||||||
|
agg.Handler()(rec2, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
body := rec2.Body.String()
|
||||||
|
if !strings.Contains(body, "nexarch_module_dms_requests_total") {
|
||||||
|
t.Fatalf("erwartet umbenannte modul-metrik 'nexarch_module_dms_requests_total' nach registrierung, body:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 3: Namenskonvention "nexarch_module_<modul>_<name>"
|
||||||
|
// wird tatsaechlich angewendet.
|
||||||
|
func TestFetchAndRename_AppliesNamingConvention(t *testing.T) {
|
||||||
|
moduleServer := fakeModuleServer("queue_depth")
|
||||||
|
defer moduleServer.Close()
|
||||||
|
|
||||||
|
agg := NewAggregator(NewCoreRegistry(), nil)
|
||||||
|
families, err := agg.fetchAndRename(context.Background(), Source{ModuleName: "mail", MetricsURL: moduleServer.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fetchAndRename: %v", err)
|
||||||
|
}
|
||||||
|
if len(families) != 1 || families[0].GetName() != "nexarch_module_mail_queue_depth" {
|
||||||
|
t.Fatalf("erwartet genau 1 metrik 'nexarch_module_mail_queue_depth', habe: %+v", families)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ein nicht erreichbares Modul darf die Aggregation der uebrigen und die
|
||||||
|
// Gesamtantwort nicht verhindern (dieselbe Resilienz wie OPS-02).
|
||||||
|
func TestHandler_UnreachableModuleDoesNotBreakAggregation(t *testing.T) {
|
||||||
|
reachable := fakeModuleServer("healthy_metric")
|
||||||
|
defer reachable.Close()
|
||||||
|
unreachable := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||||
|
unreachableURL := unreachable.URL
|
||||||
|
unreachable.Close() // sofort schliessen -> Verbindung schlaegt fehl
|
||||||
|
|
||||||
|
provider := func(ctx context.Context) ([]Source, error) {
|
||||||
|
return []Source{
|
||||||
|
{ModuleName: "ok", MetricsURL: reachable.URL},
|
||||||
|
{ModuleName: "kaputt", MetricsURL: unreachableURL},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
agg := NewAggregator(NewCoreRegistry(), provider)
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
agg.Handler()(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200 trotz einem nicht erreichbaren modul", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "nexarch_module_ok_healthy_metric") {
|
||||||
|
t.Fatal("erreichbares modul haette trotz ausfall des anderen aggregiert werden sollen")
|
||||||
|
}
|
||||||
|
if strings.Contains(body, "kaputt") {
|
||||||
|
t.Fatal("nicht erreichbares modul haette keine metrik beitragen duerfen")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruefung 1: Endpunkt antwortet unter mehreren gleichzeitigen Anfragen
|
||||||
|
// innerhalb definierter Zeit — kein unbeschraenktes Blockieren durch
|
||||||
|
// langsame Module (FetchTimeout begrenzt jeden Scrape).
|
||||||
|
func TestHandler_RespondsWithinBoundedTimeUnderLoad(t *testing.T) {
|
||||||
|
hangingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
time.Sleep(10 * time.Second) // wuerde ohne timeout jede anfrage blockieren
|
||||||
|
}))
|
||||||
|
defer hangingServer.Close()
|
||||||
|
|
||||||
|
provider := func(ctx context.Context) ([]Source, error) {
|
||||||
|
return []Source{{ModuleName: "haengend", MetricsURL: hangingServer.URL}}, nil
|
||||||
|
}
|
||||||
|
agg := NewAggregator(NewCoreRegistry(), provider)
|
||||||
|
|
||||||
|
const concurrentRequests = 10
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := time.Now()
|
||||||
|
for i := 0; i < concurrentRequests; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
agg.Handler()(rec, httptest.NewRequest(http.MethodGet, "/metrics", nil))
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Errorf("status = %d, want 200", rec.Code)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if elapsed > FetchTimeout+3*time.Second {
|
||||||
|
t.Fatalf("%d gleichzeitige anfragen brauchten %s, erwartet deutlich unter %s durch FetchTimeout",
|
||||||
|
concurrentRequests, elapsed, FetchTimeout+3*time.Second)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceStore persistiert, welche Module ihre Metriken unter welcher URL
|
||||||
|
// bereitstellen — dieselbe Postgres-basierte "kein Code-Deploy noetig"-
|
||||||
|
// Konvention wie internal/statuspage.Store.RegisterTarget (OPS-02): ein neu
|
||||||
|
// registriertes Modul erscheint automatisch in der Aggregation, sobald es
|
||||||
|
// hier eingetragen ist (Akzeptanzkriterium 2 / Pruefung 2).
|
||||||
|
type SourceStore struct {
|
||||||
|
pool *pgxpool.Pool
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSourceStore(pool *pgxpool.Pool) *SourceStore {
|
||||||
|
return &SourceStore{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SourceStore) RegisterSource(ctx context.Context, moduleName, metricsURL string) error {
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO metrics_sources (module_name, metrics_url)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (module_name) DO UPDATE SET metrics_url = $2
|
||||||
|
`, moduleName, metricsURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("metrik-quelle speichern: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provide implementiert SourceProvider direkt aus der Datenbank.
|
||||||
|
func (s *SourceStore) Provide(ctx context.Context) ([]Source, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT module_name, metrics_url FROM metrics_sources ORDER BY module_name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("metrik-quellen auflisten: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []Source
|
||||||
|
for rows.Next() {
|
||||||
|
var src Source
|
||||||
|
if err := rows.Scan(&src.ModuleName, &src.MetricsURL); err != nil {
|
||||||
|
return nil, fmt.Errorf("metrik-quelle lesen: %w", err)
|
||||||
|
}
|
||||||
|
out = append(out, src)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupSourceStoreTest(t *testing.T) (*SourceStore, func()) {
|
||||||
|
t.Helper()
|
||||||
|
adminDSN := os.Getenv("TEST_ADMIN_DSN")
|
||||||
|
if adminDSN == "" {
|
||||||
|
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
pool, err := pgxpool.New(ctx, adminDSN)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("pool: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
CREATE TABLE IF NOT EXISTS metrics_sources (module_name TEXT PRIMARY KEY, metrics_url TEXT NOT NULL)
|
||||||
|
`); err != nil {
|
||||||
|
t.Fatalf("schema: %v", err)
|
||||||
|
}
|
||||||
|
cleanup := func() { pool.Close() }
|
||||||
|
return NewSourceStore(pool), cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
// Akzeptanzkriterium 2 / Pruefung 2 auf Persistenz-Ebene: eine ueber die
|
||||||
|
// Datenbank registrierte Quelle ist sofort ueber Provide() sichtbar — genau
|
||||||
|
// der Mechanismus, der ein neues Modul ohne Core-Codeaenderung erscheinen
|
||||||
|
// laesst.
|
||||||
|
func TestSourceStore_RegisterSourceAppearsInProvide(t *testing.T) {
|
||||||
|
store, cleanup := setupSourceStoreTest(t)
|
||||||
|
defer cleanup()
|
||||||
|
ctx := context.Background()
|
||||||
|
name := fmt.Sprintf("modul-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
if err := store.RegisterSource(ctx, name, "http://example.invalid/metrics"); err != nil {
|
||||||
|
t.Fatalf("registersource: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources, err := store.Provide(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("provide: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, s := range sources {
|
||||||
|
if s.ModuleName == name {
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("erwartet %s in provide()-ergebnis, habe: %+v", name, sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// Package opsdocs enthaelt keine Laufzeitlogik — es haelt ausschliesslich
|
|
||||||
// den Pfad zum Incident-Response-Plan (Core OPS-04) fest, damit ein
|
|
||||||
// automatisierter Test (siehe opsdocs_test.go) pruefen kann, dass die
|
|
||||||
// darin geforderten Pflichtinhalte (Meldefrist, Verantwortlichkeiten,
|
|
||||||
// Audit-Log-Anknuepfung) nicht versehentlich aus dem Dokument verschwinden.
|
|
||||||
package opsdocs
|
|
||||||
|
|
||||||
// IncidentResponsePlanPath ist der Pfad relativ zum Repository-Root.
|
|
||||||
const IncidentResponsePlanPath = "docs/INCIDENT-RESPONSE-PLAN.md"
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
package opsdocs
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readPlan(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
// Test laeuft aus internal/opsdocs/ heraus, Repo-Root ist zwei Ebenen hoeher.
|
|
||||||
path := filepath.Join("..", "..", IncidentResponsePlanPath)
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("incident-response-plan nicht lesbar (%s): %v", path, err)
|
|
||||||
}
|
|
||||||
return string(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireContains(t *testing.T, content, substr, why string) {
|
|
||||||
t.Helper()
|
|
||||||
if !strings.Contains(content, substr) {
|
|
||||||
t.Fatalf("erwartet %q im incident-response-plan (%s), nicht gefunden", substr, why)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 1: Ablaufplan dokumentiert Erkennung/Eskalation/
|
|
||||||
// Meldefristen/Verantwortlichkeiten.
|
|
||||||
func TestPlan_DocumentsDetectionEscalationAndResponsibilities(t *testing.T) {
|
|
||||||
content := readPlan(t)
|
|
||||||
requireContains(t, content, "Erkennung", "Abschnitt Erkennung fehlt")
|
|
||||||
requireContains(t, content, "Eskalationskette", "Abschnitt Eskalation fehlt")
|
|
||||||
requireContains(t, content, "Incident Commander", "Verantwortlichkeits-Rolle fehlt")
|
|
||||||
requireContains(t, content, "Datenschutzbeauftragter", "DSB-Rolle fehlt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 2: DSGVO-72-Stunden-Meldefrist ist als Prozessschritt
|
|
||||||
// mit Verantwortlichem hinterlegt.
|
|
||||||
func TestPlan_Documents72HourGDPRDeadlineWithResponsibleRole(t *testing.T) {
|
|
||||||
content := readPlan(t)
|
|
||||||
requireContains(t, content, "72 Stunden", "72-Stunden-Frist fehlt")
|
|
||||||
requireContains(t, content, "Art. 33", "Verweis auf Art. 33 DSGVO fehlt")
|
|
||||||
requireContains(t, content, "Verantwortlich für die Meldung", "Zuständigkeit für die Meldung fehlt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Akzeptanzkriterium 3: Plan verweist konkret auf die Audit-Log-Quellen
|
|
||||||
// (Core AUD-01/AUD-03/AUD-05), die im Vorfall herangezogen werden.
|
|
||||||
func TestPlan_ReferencesConcreteAuditLogSources(t *testing.T) {
|
|
||||||
content := readPlan(t)
|
|
||||||
requireContains(t, content, "AUD-01", "Verweis auf AUD-01 fehlt")
|
|
||||||
requireContains(t, content, "AUD-03", "Verweis auf AUD-03 (Export) fehlt")
|
|
||||||
requireContains(t, content, "internal/audit", "konkreter Code-Pfad zum Audit-Log fehlt")
|
|
||||||
requireContains(t, content, "StreamCSV", "konkrete Export-Funktion fehlt")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zusaetzliche Absicherung: die drei geforderten Pruefungen sind im
|
|
||||||
// Dokument tatsaechlich mit einem Ergebnis (PASS/OFFEN) festgehalten,
|
|
||||||
// nicht nur als Vorhaben erwaehnt — verhindert, dass "durchgefuehrt"
|
|
||||||
// behauptet wird, ohne das Ergebnis schriftlich festzuhalten (Ticket-
|
|
||||||
// Vorgabe: "Nicht durchgefuehrte Pruefungen zaehlen als offen").
|
|
||||||
func TestPlan_RecordsAllThreeRequiredCheckResults(t *testing.T) {
|
|
||||||
content := readPlan(t)
|
|
||||||
requireContains(t, content, "Prüfung 1", "Ergebnis der Tabletop-Übung fehlt")
|
|
||||||
requireContains(t, content, "Prüfung 2", "Ergebnis der Meldefrist-Vollständigkeitsprüfung fehlt")
|
|
||||||
requireContains(t, content, "Prüfung 3", "Ergebnis der Kontaktlisten-Prüfung fehlt")
|
|
||||||
requireContains(t, content, "Status: OFFEN", "ehrlicher Offen-Status fuer die nicht durchfuehrbare Kontaktlisten-Pruefung fehlt")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE metrics_sources;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Metrics-Aggregation ueber Module hinweg (OPS-03, siehe
|
||||||
|
-- core-kanban/tickets/OPS-03.md) — welches Modul liefert seine Kennzahlen
|
||||||
|
-- unter welcher /metrics-URL.
|
||||||
|
CREATE TABLE metrics_sources (
|
||||||
|
module_name TEXT PRIMARY KEY,
|
||||||
|
metrics_url TEXT NOT NULL
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user