Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218971824b |
@@ -1,43 +0,0 @@
|
||||
// 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))
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
# 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.**
|
||||
@@ -1,19 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// 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"
|
||||
@@ -0,0 +1,68 @@
|
||||
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")
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE metrics_sources;
|
||||
@@ -1,7 +0,0 @@
|
||||
-- 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