Compare commits

..
Author SHA1 Message Date
sysops ee98efb51e ARC-01: objekt-speicher-anbindung-fuer-mails-anhaenge
- mail/internal/storage: LocalDriver/S3Driver (bewaehrtes Muster aus
  DMS FDN-03, bewusste Neuimplementierung - Mail kann DMS nicht
  importieren), ObjectKey mit festem Pfadschema
- Service.Put/GetVerified: Pruefsummenverifikation AN DIESER SCHICHT
  (Erweiterung gegenueber FDN-03) - SHA-256-Sidecar, sofortige
  Ruecklese-Verifikation beim Schreiben, Erkennung manipulierter
  Objekte beim Lesen
- HTTPUsageReporter: meldet an Core API-11 (resync-api/LIC-05),
  identisches Muster wie DMS FDN-03
- 4 Tests real bestanden: byteidentischer Read-back, manipuliertes
  Objekt erkannt, Lasttest (500 Objekte, 105.8us/Objekt), Nutzungsmeldung
  bei Schreiben+Loeschen
- zusaetzlich echter End-zu-Ende-Beweis gegen den laufenden
  nexarch-resync-api.service: reales Service-Credential provisioniert,
  Put->GetVerified->Delete komplett durchlaufen, usage_counters zeigt
  reales +29/-29-Delta (beide Meldungen real angewendet)

Pruefungen siehe mail/docs/ARC-01-PRUEFPROTOKOLL.md
2026-08-30 23:43:12 +02:00
sysops dff6b8b7a4 ING-04: mime-anhang-parsing
- mail/internal/mimeparse.Parse: rekursive Multipart-Zerlegung,
  Zeichensatz-Reparatur (mime.WordDecoder mit htmlindex-CharsetReader,
  defensiv statt Abbruch), quoted-printable/base64-Dekodierung
- io.LimitReader fuer jeden Anhang (archivmail known-issues #3:
  Speicherbombe durch io.ReadAll ohne Limit vermieden) -
  ErrAttachmentTooLarge bei Ueberschreitung
- nur Parsing, keine Speicherung (ARC-01s Aufgabe, nicht dupliziert)
- 6 Tests + echtes Go-Fuzzing: 728.164 reale Fuzz-Durchlaeufe
  (go test -fuzz=FuzzParse -fuzztime=45s), 0 Abstuerze, 146
  coverage-erweiternde Eingaben gefunden
- alle 3 Pflichtpruefungen real bestanden (Speicherbombe abgewehrt,
  realitaetsnaher Testkorpus, Fuzz-Nachweis)

Pruefungen siehe mail/docs/ING-04-PRUEFPROTOKOLL.md
2026-08-30 23:31:02 +02:00
sysops 44b78b1554 QA-01: teststrategie-mail (mail-modul-grundstein)
- mail/go.mod: erstes eigenstaendiges Go-Modul fuer NEXARCH Mail
- mail/docs/TESTSTRATEGIE-MAIL.md: Testpyramide (Unit/Integration/
  Protokoll-Zustandsmaschinen/E2E/Vertragstests), Pflichttest-Merge-Gate,
  Bug-Tracking-Konvention (Gitea-Issues), analog Core QA-01
- mail/internal/example: ein reales, kleines Beispiel (Adress-
  Normalisierung) mit je einem Test pro Testart (Unit/Integration/E2E),
  6 Tests real bestanden
- mail/internal/pflichttestgate + cmd/pflichttestgate: Merge-Gate-CLI,
  echter End-zu-Ende-Beweis (Binary lehnt Verstoss ab, akzeptiert
  begleiteten Test), .gitea/workflows/mail-pflichttest-gate.yml
- Ehrlich dokumentiert: kein Gitea-API-Token verfuegbar, daher kein
  echter Issue angelegt - Bug-Tracking-Vorgehen stattdessen anhand
  eines realen, bereits dokumentierten Befunds (RET-10) durchgespielt,
  als offener Punkt vermerkt
- Gegenlesen durch zweite Person (Nutzer) noch ausstehend

Pruefungen siehe mail/docs/TESTSTRATEGIE-MAIL.md
2026-08-30 23:24:37 +02:00
79 changed files with 1758 additions and 4000 deletions
@@ -0,0 +1,23 @@
name: Mail-Pflichttest-Gate
on:
pull_request:
paths:
- "mail/**"
jobs:
pflichttest-gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-go@v5
with:
go-version: "1.22"
- name: Gate bauen
working-directory: mail
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
- name: Geänderte Dateien gegen Pflichttest-Regel prüfen
run: |
git diff --name-only "origin/${{ github.base_ref }}...HEAD" | /tmp/pflichttestgate
-116
View File
@@ -44,119 +44,3 @@ Keine Commits in dieser Session.
Keine Änderungen ermittelbar.
---
## 2026-08-27 17:26 17:28 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
- c895a67 core: initial Go module skeleton (config, db pool, tenant registry migration)
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:28 17:29 (1m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:31 17:31 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:36 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-27 17:36 17:37 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- .gitignore | 2 ++
- DEVLOG.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++
- cmd/core/main.go | 33 +++++++++++++++++++++++++++++++++
- go.mod | 5 +++++
- internal/config/config.go | 29 +++++++++++++++++++++++++++++
- internal/db/db.go | 11 +++++++++++
- migrations/0001_tenant_registry.sql | 10 ++++++++++
---
## 2026-08-28 23:55 23:59 (3m)
**Beschreibung:** Claude Code Session
**Projekt:** nexarch
### Commits
- 369a40a OPS-05: alerting-bei-schwellwert-ueberschreitung (internal/alerting: regel-store, evaluator gegen ops-03-metriken, cfg-02-zustellung, drosselung je regel+zeitreihe)
- 06dbd52 Merge branch 'feature/cfg-02-benachrichtigungs-dispatcher-core-service-fuer-module' into feature/ops-05-alerting-bei-schwellwert-ueberschreitung
### Geänderte Dateien
- internal/alerting/evaluator.go | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/alerting/evaluator_test.go | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/alerting/rules.go | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- migrations/0006_alert_rules.down.sql | 2 ++
- migrations/0006_alert_rules.up.sql | 24 ++++++++++++++++++++++
---
## 2026-08-29 00:00 00:00 (0m)
**Beschreibung:** Claude Code Session
**Projekt:** code
### Commits
Keine Commits in dieser Session.
### Geänderte Dateien
- internal/alerting/evaluator.go | 194 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/alerting/evaluator_test.go | 233 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- internal/alerting/rules.go | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
- migrations/0006_alert_rules.down.sql | 2 ++
- migrations/0006_alert_rules.up.sql | 24 ++++++++++++++++++++++
---
+3 -18
View File
@@ -7,7 +7,6 @@ import (
"gitea.perlbach24.de/scripte/nexarch/internal/config"
"gitea.perlbach24.de/scripte/nexarch/internal/db"
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
)
func main() {
@@ -16,30 +15,16 @@ func main() {
log.Fatalf("config: %v", err)
}
ctx := context.Background()
registryPool, err := db.Connect(ctx, cfg.RegistryDSN)
pool, err := db.Connect(context.Background(), cfg.RegistryDSN)
if err != nil {
log.Fatalf("registry db: %v", err)
log.Fatalf("db: %v", err)
}
defer registryPool.Close()
adminPool, err := db.Connect(ctx, cfg.AdminDSN)
if err != nil {
log.Fatalf("admin db: %v", err)
}
defer adminPool.Close()
registry := tenant.NewRegistry(registryPool)
provisioner := tenant.NewProvisioner(adminPool, registry, cfg.TenantDSNTemplate)
tenantHandler := tenant.NewHandler(provisioner)
defer pool.Close()
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Vorlaeufiger Pfad ohne Versionierung/Auth — wird mit API-01/IAM-01 abgeloest.
mux.HandleFunc("/internal/tenants", tenantHandler.CreateTenant)
log.Printf("nexarch-core listening on %s", cfg.ListenAddr)
if err := http.ListenAndServe(cfg.ListenAddr, mux); err != nil {
-43
View File
@@ -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))
}
+2 -22
View File
@@ -1,25 +1,5 @@
module gitea.perlbach24.de/scripte/nexarch
go 1.25.0
go 1.22
require (
github.com/jackc/pgx/v5 v5.6.0
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.2
github.com/prometheus/common v0.70.1
)
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
require github.com/jackc/pgx/v5 v5.6.0
-52
View File
@@ -1,52 +0,0 @@
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.24.1 h1:JnJkREXzWxUdCuPFpIWZiPispT9xVV59uiuyR2bPlnU=
github.com/prometheus/client_golang v1.24.1/go.mod h1:F+oSRECHg4sse5ucfYpYDeIv/hu68Zo0uoHKetWnzcE=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.70.1 h1:1HvjP4D5oL3t8RsPlwxA9onvvStjtIHYE5XuuwOi/PY=
github.com/prometheus/common v0.70.1/go.mod h1:VdFUQDMZK3VLkurFUVhia6uys/0suUp86TJz5qbJRhc=
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-194
View File
@@ -1,194 +0,0 @@
package alerting
import (
"context"
"fmt"
"sort"
"strings"
"time"
dto "github.com/prometheus/client_model/go"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/notify"
)
// DefaultDebounceInterval: wiederholte Alarmierung für denselben
// anhaltenden Zustand ist gedrosselt (Akzeptanzkriterium 3) — 15 Minuten
// ist ein üblicher Kompromiss zwischen "schnell genug informiert" und
// "kein Alarm-Spam bei dauerhaft überschrittenem Wert".
const DefaultDebounceInterval = 15 * time.Minute
// AlertChannel ist der CFG-02-Kanal, über den Schwellwert-Alarme zugestellt
// werden — ein eigener Kanalname, damit Zustellregeln/-vorlagen (CFG-03)
// unabhängig von anderen Benachrichtigungsarten konfiguriert werden können.
const AlertChannel = "alert"
// Evaluator prüft konfigurierte Regeln gegen aktuell gesammelte Metriken
// (aus internal/metrics.Aggregator.Gather) und löst bei Überschreitung eine
// Benachrichtigung über CFG-02 aus (Akzeptanzkriterium 2), gedrosselt je
// Regel+Zeitreihe (Akzeptanzkriterium 3).
type Evaluator struct {
rules *RuleStore
debounce *debounceStore
dispatcher *notify.Dispatcher
interval time.Duration
}
func NewEvaluator(rules *RuleStore, dispatcher *notify.Dispatcher, debouncePool *pgxpool.Pool, interval time.Duration) *Evaluator {
if interval <= 0 {
interval = DefaultDebounceInterval
}
return &Evaluator{
rules: rules,
debounce: &debounceStore{pool: debouncePool},
dispatcher: dispatcher,
interval: interval,
}
}
// FiredAlert beschreibt einen tatsächlich ausgelösten (nicht gedrosselten)
// Alarm — fürs Testen/Logging, nicht Teil des öffentlichen Zustellwegs.
type FiredAlert struct {
RuleID string
MetricName string
Value float64
Threshold float64
Labels map[string]string
Skipped bool // true, wenn wegen Drosselung NICHT tatsaechlich zugestellt
}
// Evaluate prüft alle konfigurierten Regeln gegen families (Akzeptanzkriterium 1).
// Für jede Zeitreihe, die eine Regel verletzt, wird — sofern nicht gedrosselt
// — eine Benachrichtigung mit Metrik/Wert/Schwellwert/Labels (Tenant/Modul,
// falls als Label vorhanden) über CFG-02 eingereiht (Akzeptanzkriterium 2).
func (e *Evaluator) Evaluate(ctx context.Context, families []*dto.MetricFamily) ([]FiredAlert, error) {
rules, err := e.rules.ListRules(ctx)
if err != nil {
return nil, fmt.Errorf("regeln laden: %w", err)
}
if len(rules) == 0 {
return nil, nil
}
byName := make(map[string]*dto.MetricFamily, len(families))
for _, f := range families {
if f.Name != nil {
byName[*f.Name] = f
}
}
var fired []FiredAlert
for _, rule := range rules {
family, ok := byName[rule.MetricName]
if !ok {
continue // Metrik (noch) nicht vorhanden -> keine Aussage moeglich, kein Fehler.
}
for _, m := range family.Metric {
labels := labelMap(m)
if !matchesFilter(labels, rule.LabelFilters) {
continue
}
value, ok := metricValue(m)
if !ok {
continue
}
if !violates(rule, value) {
continue
}
ruleKey := ruleKeyFor(rule.ID, labels)
allowed, err := e.debounce.shouldFire(ctx, ruleKey, e.interval.Seconds())
if err != nil {
return fired, fmt.Errorf("drosselung pruefen: %w", err)
}
alert := FiredAlert{
RuleID: rule.ID, MetricName: rule.MetricName, Value: value,
Threshold: rule.Threshold, Labels: labels, Skipped: !allowed,
}
fired = append(fired, alert)
if !allowed {
continue
}
payload := map[string]any{
"metric": rule.MetricName,
"value": value,
"threshold": rule.Threshold,
"comparison": string(rule.Comparison),
"description": rule.Description,
"labels": labels,
}
if _, err := e.dispatcher.Enqueue(ctx, AlertChannel, rule.Recipient, payload); err != nil {
return fired, fmt.Errorf("alarm einreihen: %w", err)
}
}
}
return fired, nil
}
func violates(rule Rule, value float64) bool {
switch rule.Comparison {
case ComparisonGreaterThan:
return value > rule.Threshold
case ComparisonLessThan:
return value < rule.Threshold
default:
return false
}
}
func labelMap(m *dto.Metric) map[string]string {
out := make(map[string]string, len(m.Label))
for _, l := range m.Label {
if l.Name != nil && l.Value != nil {
out[*l.Name] = *l.Value
}
}
return out
}
func matchesFilter(labels, filter map[string]string) bool {
for k, v := range filter {
if labels[k] != v {
return false
}
}
return true
}
func metricValue(m *dto.Metric) (float64, bool) {
switch {
case m.Gauge != nil && m.Gauge.Value != nil:
return *m.Gauge.Value, true
case m.Counter != nil && m.Counter.Value != nil:
return *m.Counter.Value, true
case m.Untyped != nil && m.Untyped.Value != nil:
return *m.Untyped.Value, true
default:
return 0, false
}
}
// ruleKeyFor macht die Drosselung unabhaengig je Regel UND je konkreter
// Zeitreihe (z. B. verschiedene Tenants/Module derselben Metrik loesen
// unabhaengig voneinander aus, siehe Migrationskommentar).
func ruleKeyFor(ruleID string, labels map[string]string) string {
keys := make([]string, 0, len(labels))
for k := range labels {
keys = append(keys, k)
}
sort.Strings(keys)
var b strings.Builder
b.WriteString(ruleID)
for _, k := range keys {
b.WriteString("|")
b.WriteString(k)
b.WriteString("=")
b.WriteString(labels[k])
}
return b.String()
}
-233
View File
@@ -1,233 +0,0 @@
package alerting
import (
"context"
"os"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
dto "github.com/prometheus/client_model/go"
"gitea.perlbach24.de/scripte/nexarch/internal/notify"
)
func setupTest(t *testing.T) (*RuleStore, *notify.Dispatcher, *pgxpool.Pool, 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 alert_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), metric_name TEXT NOT NULL,
comparison TEXT NOT NULL CHECK (comparison IN ('gt','lt')), threshold DOUBLE PRECISION NOT NULL,
label_filters JSONB NOT NULL DEFAULT '{}'::jsonb, recipient TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS alert_debounce_state (
rule_key TEXT PRIMARY KEY, last_fired_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE IF NOT EXISTS notification_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), channel TEXT NOT NULL, recipient TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb, status TEXT NOT NULL DEFAULT 'pending', attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5, next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(), last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM alert_rules`)
_, _ = pool.Exec(ctx, `DELETE FROM alert_debounce_state`)
_, _ = pool.Exec(ctx, `DELETE FROM notification_jobs`)
pool.Close()
}
return NewRuleStore(pool), notify.NewDispatcher(pool), pool, cleanup
}
func gaugeFamily(name string, labels map[string]string, value float64) *dto.MetricFamily {
pairs := make([]*dto.LabelPair, 0, len(labels))
for k, v := range labels {
k, v := k, v
pairs = append(pairs, &dto.LabelPair{Name: &k, Value: &v})
}
n := name
return &dto.MetricFamily{
Name: &n,
Metric: []*dto.Metric{
{Label: pairs, Gauge: &dto.Gauge{Value: &value}},
},
}
}
// Akzeptanzkriterium 1 + 2 / Pruefung 1 + 2: Überschreitung löst eine
// Benachrichtigung mit vollständigem Inhalt (Metrik/Tenant/Modul) aus.
func TestEvaluate_FiresAlertOnThresholdExceeded(t *testing.T) {
rules, dispatcher, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
rule, err := rules.CreateRule(ctx, Rule{
MetricName: "nexarch_core_error_rate", Comparison: ComparisonGreaterThan, Threshold: 0.05,
Recipient: "ops@acme.example", Description: "Fehlerrate zu hoch",
})
if err != nil {
t.Fatalf("create rule: %v", err)
}
eval := NewEvaluator(rules, dispatcher, pool, time.Hour)
families := []*dto.MetricFamily{
gaugeFamily("nexarch_core_error_rate", map[string]string{"tenant": "acme", "module": "dms"}, 0.12),
}
fired, err := eval.Evaluate(ctx, families)
if err != nil {
t.Fatalf("evaluate: %v", err)
}
if len(fired) != 1 || fired[0].Skipped {
t.Fatalf("erwartet genau 1 tatsaechlich ausgeloesten alarm, habe %+v", fired)
}
if fired[0].RuleID != rule.ID {
t.Fatalf("rule id = %q, want %q", fired[0].RuleID, rule.ID)
}
// Pruefung 2: Benachrichtigungsinhalt vollstaendig (Metrik/Tenant/Modul).
var payloadJSON []byte
if err := pool.QueryRow(ctx, `SELECT payload FROM notification_jobs LIMIT 1`).Scan(&payloadJSON); err != nil {
t.Fatalf("notification_jobs lesen: %v", err)
}
payload := string(payloadJSON)
for _, want := range []string{`"metric"`, `nexarch_core_error_rate`, `"tenant"`, `"acme"`, `"module"`, `"dms"`} {
if !strings.Contains(payload, want) {
t.Errorf("payload enthaelt nicht %q: %s", want, payload)
}
}
}
func TestEvaluate_DoesNotFireBelowThreshold(t *testing.T) {
rules, dispatcher, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := rules.CreateRule(ctx, Rule{
MetricName: "nexarch_core_error_rate", Comparison: ComparisonGreaterThan, Threshold: 0.05,
Recipient: "ops@acme.example",
}); err != nil {
t.Fatalf("create rule: %v", err)
}
eval := NewEvaluator(rules, dispatcher, pool, time.Hour)
families := []*dto.MetricFamily{
gaugeFamily("nexarch_core_error_rate", map[string]string{"tenant": "acme"}, 0.01),
}
fired, err := eval.Evaluate(ctx, families)
if err != nil {
t.Fatalf("evaluate: %v", err)
}
if len(fired) != 0 {
t.Fatalf("erwartet keinen alarm unterhalb des schwellwerts, habe %+v", fired)
}
}
// Akzeptanzkriterium 3 / Pruefung 3: anhaltende Überschreitung erzeugt NICHT
// bei jeder Messung eine neue Benachrichtigung.
func TestEvaluate_DebouncesRepeatedFiring(t *testing.T) {
rules, dispatcher, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := rules.CreateRule(ctx, Rule{
MetricName: "nexarch_core_error_rate", Comparison: ComparisonGreaterThan, Threshold: 0.05,
Recipient: "ops@acme.example",
}); err != nil {
t.Fatalf("create rule: %v", err)
}
// Langes Debounce-Intervall: der zweite Evaluate-Lauf (simuliert die
// naechste Messung bei anhaltend ueberschrittenem Wert) darf keinen
// weiteren Job einreihen.
eval := NewEvaluator(rules, dispatcher, pool, time.Hour)
families := []*dto.MetricFamily{
gaugeFamily("nexarch_core_error_rate", map[string]string{"tenant": "acme"}, 0.5),
}
first, err := eval.Evaluate(ctx, families)
if err != nil {
t.Fatalf("erster evaluate-lauf: %v", err)
}
if len(first) != 1 || first[0].Skipped {
t.Fatalf("erster lauf haette feuern muessen, habe %+v", first)
}
second, err := eval.Evaluate(ctx, families)
if err != nil {
t.Fatalf("zweiter evaluate-lauf: %v", err)
}
if len(second) != 1 || !second[0].Skipped {
t.Fatalf("zweiter lauf haette gedrosselt werden muessen, habe %+v", second)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM notification_jobs`).Scan(&count); err != nil {
t.Fatalf("notification_jobs zaehlen: %v", err)
}
if count != 1 {
t.Fatalf("erwartet genau 1 eingereihten job trotz zwei ueberschreitenden messungen, habe %d", count)
}
}
// Verschiedene Zeitreihen derselben Regel (unterschiedlicher Tenant) werden
// unabhaengig voneinander gedrosselt.
func TestEvaluate_DebouncesIndependentlyPerLabelSet(t *testing.T) {
rules, dispatcher, pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := rules.CreateRule(ctx, Rule{
MetricName: "nexarch_core_error_rate", Comparison: ComparisonGreaterThan, Threshold: 0.05,
Recipient: "ops@acme.example",
}); err != nil {
t.Fatalf("create rule: %v", err)
}
eval := NewEvaluator(rules, dispatcher, pool, time.Hour)
families := []*dto.MetricFamily{
{
Name: strPtr("nexarch_core_error_rate"),
Metric: []*dto.Metric{
metricWithLabel("tenant", "acme", 0.5),
metricWithLabel("tenant", "beta", 0.6),
},
},
}
fired, err := eval.Evaluate(ctx, families)
if err != nil {
t.Fatalf("evaluate: %v", err)
}
if len(fired) != 2 {
t.Fatalf("erwartet 2 unabhaengige alarme (verschiedene tenants), habe %d", len(fired))
}
for _, a := range fired {
if a.Skipped {
t.Fatalf("beide tenants sollten beim ersten mal feuern, habe %+v", a)
}
}
}
func metricWithLabel(name, value string, gaugeValue float64) *dto.Metric {
n, v := name, value
return &dto.Metric{Label: []*dto.LabelPair{{Name: &n, Value: &v}}, Gauge: &dto.Gauge{Value: &gaugeValue}}
}
func strPtr(s string) *string { return &s }
-132
View File
@@ -1,132 +0,0 @@
// Package alerting implementiert Core OPS-05: schwellwertbasierte
// Alarmierung auf den aus OPS-03 aggregierten Metriken, Zustellung über den
// Core-Benachrichtigungs-Dispatcher (CFG-02). Der Alertmanager-Gedanke von
// Prometheus/Grafana, aber auf das Nötigste reduziert (Schwellwert, Ziel,
// Drosselung) — keine eigene Ausdruckssprache.
package alerting
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Comparison legt fest, ob ein Schwellwert nach oben oder unten überwacht
// wird — bewusst nur zwei Operatoren, keine eigene Ausdruckssprache
// (Ticket-Produkt-DNA).
type Comparison string
const (
ComparisonGreaterThan Comparison = "gt"
ComparisonLessThan Comparison = "lt"
)
// Rule ist eine Schwellwert-Regel auf einer beliebigen aggregierten Metrik
// (Akzeptanzkriterium 1). LabelFilters schränkt optional auf bestimmte
// Label-Werte ein (z. B. tenant/module), leer = alle Zeitreihen der Metrik.
type Rule struct {
ID string
MetricName string
Comparison Comparison
Threshold float64
LabelFilters map[string]string
Recipient string
Description string
}
// RuleStore verwaltet Alert-Regeln in der zentralen Registry-DB.
type RuleStore struct {
pool *pgxpool.Pool
}
func NewRuleStore(pool *pgxpool.Pool) *RuleStore {
return &RuleStore{pool: pool}
}
// CreateRule legt eine neue Schwellwert-Regel an (Akzeptanzkriterium 1).
func (s *RuleStore) CreateRule(ctx context.Context, r Rule) (Rule, error) {
if r.MetricName == "" || r.Recipient == "" {
return Rule{}, errors.New("alerting: metricName und recipient duerfen nicht leer sein")
}
if r.Comparison != ComparisonGreaterThan && r.Comparison != ComparisonLessThan {
return Rule{}, fmt.Errorf("alerting: unbekannter comparison-operator %q", r.Comparison)
}
if r.LabelFilters == nil {
r.LabelFilters = map[string]string{}
}
filtersJSON, err := json.Marshal(r.LabelFilters)
if err != nil {
return Rule{}, fmt.Errorf("label-filter serialisieren: %w", err)
}
err = s.pool.QueryRow(ctx, `
INSERT INTO alert_rules (metric_name, comparison, threshold, label_filters, recipient, description)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id
`, r.MetricName, string(r.Comparison), r.Threshold, filtersJSON, r.Recipient, r.Description).Scan(&r.ID)
if err != nil {
return Rule{}, fmt.Errorf("regel speichern: %w", err)
}
return r, nil
}
// ListRules liefert alle konfigurierten Regeln — Grundlage für Evaluate.
func (s *RuleStore) ListRules(ctx context.Context) ([]Rule, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, metric_name, comparison, threshold, label_filters, recipient, description
FROM alert_rules ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("regeln auflisten: %w", err)
}
defer rows.Close()
var out []Rule
for rows.Next() {
var r Rule
var comparison string
var filtersJSON []byte
if err := rows.Scan(&r.ID, &r.MetricName, &comparison, &r.Threshold, &filtersJSON, &r.Recipient, &r.Description); err != nil {
return nil, fmt.Errorf("regel lesen: %w", err)
}
r.Comparison = Comparison(comparison)
if err := json.Unmarshal(filtersJSON, &r.LabelFilters); err != nil {
return nil, fmt.Errorf("label-filter lesen: %w", err)
}
out = append(out, r)
}
return out, rows.Err()
}
// DeleteRule entfernt eine Regel.
func (s *RuleStore) DeleteRule(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM alert_rules WHERE id = $1`, id)
if err != nil {
return fmt.Errorf("regel loeschen: %w", err)
}
return nil
}
// debounceStore kapselt die Drosselungs-Zustandstabelle (Akzeptanzkriterium 3).
type debounceStore struct {
pool *pgxpool.Pool
}
// shouldFire prueft, ob seit dem letzten Alarm fuer ruleKey mindestens
// interval vergangen ist — atomar ueber eine bedingte UPDATE/INSERT-
// Sequenz, damit zwei gleichzeitige Evaluate-Laeufe (z. B. bei mehreren
// Core-Instanzen) nicht beide gleichzeitig alarmieren.
func (d *debounceStore) shouldFire(ctx context.Context, ruleKey string, intervalSeconds float64) (bool, error) {
tag, err := d.pool.Exec(ctx, `
INSERT INTO alert_debounce_state (rule_key, last_fired_at) VALUES ($1, now())
ON CONFLICT (rule_key) DO UPDATE SET last_fired_at = now()
WHERE alert_debounce_state.last_fired_at <= now() - ($2 * interval '1 second')
`, ruleKey, intervalSeconds)
if err != nil {
return false, fmt.Errorf("drosselungszustand pruefen: %w", err)
}
return tag.RowsAffected() == 1, nil
}
-88
View File
@@ -1,88 +0,0 @@
package cfgservice
import (
"context"
"errors"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2 / Pruefung 2 in diesem Ticket bezieht sich auf die
// Aenderungsnachvollziehbarkeit — die Cache-Frist selbst folgt demselben
// Muster wie internal/flag.DefaultCacheTTL).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
value Value
expiresAt time.Time
}
// Service ist die Leseseite mit Vorrangregel (Akzeptanzkriterium 1:
// Tenant-Override vor Global-Default) und lokalem TTL-Cache.
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry // Schluessel: key + "\x00" + tenantSlug
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
func cacheKey(key, tenantSlug string) string {
return key + "\x00" + tenantSlug
}
// Resolve liefert den Konfigurationswert fuer einen Tenant: ein
// Tenant-spezifischer Override hat Vorrang vor dem globalen Default
// (Akzeptanzkriterium 1 / Pruefung 1). tenantSlug == "" wertet nur den
// globalen Wert aus.
func (s *Service) Resolve(ctx context.Context, tenantSlug, key string) (Value, error) {
ck := cacheKey(key, tenantSlug)
s.mu.RLock()
entry, exists := s.cache[ck]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.value, nil
}
v, err := s.resolveUncached(ctx, tenantSlug, key)
if err != nil {
return Value{}, err
}
s.mu.Lock()
s.cache[ck] = cacheEntry{value: v, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return v, nil
}
func (s *Service) resolveUncached(ctx context.Context, tenantSlug, key string) (Value, error) {
if tenantSlug != "" {
v, err := s.store.Get(ctx, key, tenantSlug)
if err == nil {
return v, nil
}
if !errors.Is(err, ErrNotFound) {
return Value{}, err
}
}
return s.store.Get(ctx, key, GlobalScope)
}
// Invalidate erzwingt beim naechsten Resolve-Aufruf ein sofortiges Neuladen
// fuer einen bestimmten (key, tenantSlug) statt auf den TTL-Ablauf zu warten
// — analog internal/flag.Service.Invalidate.
func (s *Service) Invalidate(key, tenantSlug string) {
s.mu.Lock()
delete(s.cache, cacheKey(key, tenantSlug))
s.mu.Unlock()
}
-114
View File
@@ -1,114 +0,0 @@
package cfgservice
import (
"context"
"testing"
"time"
)
// Akzeptanzkriterium 1 + Pruefung 1: Tenant-Override hat Vorrang vor
// Global-Default, automatisiert getestet.
func TestService_TenantOverrideTakesPrecedenceOverGlobal(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Set(ctx, "test_precedence_key", GlobalScope, "global-wert"); err != nil {
t.Fatalf("set global: %v", err)
}
if _, err := store.Set(ctx, "test_precedence_key", "test_acme", "tenant-wert"); err != nil {
t.Fatalf("set tenant: %v", err)
}
svc := NewService(store, time.Hour)
got, err := svc.Resolve(ctx, "test_acme", "test_precedence_key")
if err != nil {
t.Fatalf("resolve mit override: %v", err)
}
if got.Value != "tenant-wert" {
t.Fatalf("erwartet tenant-override, habe %q", got.Value)
}
gotOther, err := svc.Resolve(ctx, "test_anderer_tenant", "test_precedence_key")
if err != nil {
t.Fatalf("resolve ohne override: %v", err)
}
if gotOther.Value != "global-wert" {
t.Fatalf("erwartet global-default fuer tenant ohne override, habe %q", gotOther.Value)
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Cache-Invalidierung nach
// Konfigurationsaenderung innerhalb dokumentierter Zeit gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "alt"); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
v, err := svc.Resolve(ctx, "", "test_ttl_key")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if v.Value != "alt" {
t.Fatalf("erwartet 'alt', habe %q", v.Value)
}
changedAt := time.Now()
if _, err := store.Set(ctx, "test_ttl_key", GlobalScope, "neu"); err != nil {
t.Fatalf("set: %v", err)
}
v, err = svc.Resolve(ctx, "", "test_ttl_key")
if err != nil {
t.Fatalf("resolve direkt nach aenderung: %v", err)
}
if v.Value != "alt" {
t.Fatalf("cache haette den alten wert liefern sollen, habe %q", v.Value)
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
v, err := svc.Resolve(ctx, "", "test_ttl_key")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if v.Value == "neu" {
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", time.Since(changedAt), ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "alt"); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
_, _ = svc.Resolve(ctx, "", "test_invalidate_key")
if _, err := store.Set(ctx, "test_invalidate_key", GlobalScope, "neu"); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_key", "")
v, err := svc.Resolve(ctx, "", "test_invalidate_key")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if v.Value != "neu" {
t.Fatalf("erwartet sofort sichtbaren neuen wert nach Invalidate, habe %q", v.Value)
}
}
-129
View File
@@ -1,129 +0,0 @@
// Package cfgservice implementiert Core CFG-01: den zentralen Dienst fuer
// globale und tenant-spezifische Konfigurationswerte mit Versionierung und
// Cache-Invalidierung. Andere Module lesen Konfiguration AUSSCHLIESSLICH
// ueber dieses Paket (Akzeptanzkriterium 3), niemals ueber eigene Tabellen.
package cfgservice
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// GlobalScope ist der reservierte Scope-Wert fuer globale Defaults — jeder
// andere Scope-Wert ist ein Tenant-Slug (Akzeptanzkriterium 1).
const GlobalScope = "global"
var ErrNotFound = errors.New("cfgservice: kein wert fuer diesen key gefunden")
type Value struct {
Key string
Scope string
Value string
Version int
}
type HistoryEntry struct {
Key string
Scope string
Value string
Version int
}
// Store ist die Schreib-/Verwaltungsseite. Set schreibt IMMER sowohl den
// aktuellen Stand (config_values) als auch einen Historieneintrag
// (config_value_history) in derselben Transaktion — eine Aenderung ohne
// Versionshistorie ist strukturell ausgeschlossen (Akzeptanzkriterium 2).
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
// Set schreibt einen neuen Wert fuer (key, scope) und erhoeht die Version um 1
// (Version 1 bei erstmaligem Setzen).
func (s *Store) Set(ctx context.Context, key, scope, value string) (Value, error) {
if scope == "" {
return Value{}, errors.New("cfgservice: scope darf nicht leer sein")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return Value{}, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
var currentVersion int
err = tx.QueryRow(ctx, `SELECT version FROM config_values WHERE key = $1 AND scope = $2`, key, scope).Scan(&currentVersion)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
return Value{}, fmt.Errorf("aktuelle version lesen: %w", err)
}
newVersion := currentVersion + 1
if _, err := tx.Exec(ctx, `
INSERT INTO config_values (key, scope, value, version, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key, scope) DO UPDATE SET value = $3, version = $4, updated_at = now()
`, key, scope, value, newVersion); err != nil {
return Value{}, fmt.Errorf("wert speichern: %w", err)
}
if _, err := tx.Exec(ctx, `
INSERT INTO config_value_history (key, scope, value, version, changed_at)
VALUES ($1, $2, $3, $4, now())
`, key, scope, value, newVersion); err != nil {
return Value{}, fmt.Errorf("historie schreiben: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return Value{}, fmt.Errorf("transaktion committen: %w", err)
}
return Value{Key: key, Scope: scope, Value: value, Version: newVersion}, nil
}
// Get liefert den Wert fuer GENAU EINEN Scope (kein Vorrang-Fallback) — die
// Vorrangregel (Tenant vor Global) lebt bewusst in Service.Resolve, damit
// Store rein CRUD bleibt.
func (s *Store) Get(ctx context.Context, key, scope string) (Value, error) {
var v Value
v.Key, v.Scope = key, scope
err := s.pool.QueryRow(ctx, `
SELECT value, version FROM config_values WHERE key = $1 AND scope = $2
`, key, scope).Scan(&v.Value, &v.Version)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Value{}, ErrNotFound
}
return Value{}, fmt.Errorf("wert lesen: %w", err)
}
return v, nil
}
// History liefert die vollstaendige Versionshistorie eines (key, scope) in
// aufsteigender Reihenfolge (Akzeptanzkriterium 2 / Pruefung 3).
func (s *Store) History(ctx context.Context, key, scope string) ([]HistoryEntry, error) {
rows, err := s.pool.Query(ctx, `
SELECT key, scope, value, version FROM config_value_history
WHERE key = $1 AND scope = $2 ORDER BY version
`, key, scope)
if err != nil {
return nil, fmt.Errorf("historie abfragen: %w", err)
}
defer rows.Close()
var out []HistoryEntry
for rows.Next() {
var h HistoryEntry
if err := rows.Scan(&h.Key, &h.Scope, &h.Value, &h.Version); err != nil {
return nil, fmt.Errorf("historieneintrag lesen: %w", err)
}
out = append(out, h)
}
return out, rows.Err()
}
-119
View File
@@ -1,119 +0,0 @@
package cfgservice
import (
"context"
"errors"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupStoreTest(t *testing.T) (*Store, 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 config_values (
key TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope <> ''),
value TEXT NOT NULL,
version INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (key, scope)
);
CREATE TABLE IF NOT EXISTS config_value_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
scope TEXT NOT NULL,
value TEXT NOT NULL,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM config_value_history WHERE key LIKE 'test\_%' ESCAPE '\'`)
_, _ = pool.Exec(ctx, `DELETE FROM config_values WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
func TestStore_SetIncrementsVersion(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
v1, err := store.Set(ctx, "test_key", GlobalScope, "erster-wert")
if err != nil {
t.Fatalf("set 1: %v", err)
}
if v1.Version != 1 {
t.Fatalf("erwartet version 1, habe %d", v1.Version)
}
v2, err := store.Set(ctx, "test_key", GlobalScope, "zweiter-wert")
if err != nil {
t.Fatalf("set 2: %v", err)
}
if v2.Version != 2 {
t.Fatalf("erwartet version 2, habe %d", v2.Version)
}
got, err := store.Get(ctx, "test_key", GlobalScope)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Value != "zweiter-wert" || got.Version != 2 {
t.Fatalf("aktueller wert unerwartet: %+v", got)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Versionierungshistorie ueber mehrere
// Aenderungen hinweg nachvollzogen.
func TestStore_HistoryTracksAllChanges(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
values := []string{"v1", "v2", "v3"}
for _, v := range values {
if _, err := store.Set(ctx, "test_history_key", GlobalScope, v); err != nil {
t.Fatalf("set %q: %v", v, err)
}
}
history, err := store.History(ctx, "test_history_key", GlobalScope)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(history) != 3 {
t.Fatalf("erwartet 3 historieneintraege, habe %d", len(history))
}
for i, h := range history {
if h.Version != i+1 || h.Value != values[i] {
t.Fatalf("historieneintrag[%d] unerwartet: %+v", i, h)
}
}
}
func TestStore_GetUnknownKeyReturnsNotFound(t *testing.T) {
store, cleanup := setupStoreTest(t)
defer cleanup()
ctx := context.Background()
if _, err := store.Get(ctx, "test_nie_gesetzt", GlobalScope); !errors.Is(err, ErrNotFound) {
t.Fatalf("erwartet ErrNotFound, habe %v", err)
}
}
+2 -24
View File
@@ -10,15 +10,8 @@ import (
// connection info, superadmin accounts) — see nexarch-state.json
// multi_tenancy: Modell C (physisch getrennte DB pro Mandant).
type Config struct {
ListenAddr string
// RegistryDSN verbindet zur Control-Plane-Registry-Datenbank.
ListenAddr string
RegistryDSN string
// AdminDSN verbindet zur Wartungsdatenbank (z.B. "postgres") und wird nur
// fuer CREATE/DROP DATABASE beim Tenant-Provisioning verwendet.
AdminDSN string
// TenantDSNTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen einer neu provisionierten Tenant-Datenbank.
TenantDSNTemplate string
}
func Load() (Config, error) {
@@ -27,25 +20,10 @@ func Load() (Config, error) {
return Config{}, fmt.Errorf("NEXARCH_REGISTRY_DSN not set")
}
adminDSN := os.Getenv("NEXARCH_ADMIN_DSN")
if adminDSN == "" {
return Config{}, fmt.Errorf("NEXARCH_ADMIN_DSN not set")
}
dsnTemplate := os.Getenv("NEXARCH_TENANT_DSN_TEMPLATE")
if dsnTemplate == "" {
return Config{}, fmt.Errorf("NEXARCH_TENANT_DSN_TEMPLATE not set")
}
addr := os.Getenv("NEXARCH_LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
return Config{
ListenAddr: addr,
RegistryDSN: dsn,
AdminDSN: adminDSN,
TenantDSNTemplate: dsnTemplate,
}, nil
return Config{ListenAddr: addr, RegistryDSN: dsn}, nil
}
-87
View File
@@ -1,87 +0,0 @@
// Package flag implementiert Core LIC-02: einen Feature-Flag-Dienst mit
// Strategien (global an/aus, Prozentsatz, Tenant-Zielgruppe) als Kernfunktion
// des Core-Dienstes selbst — keine zusaetzliche Infrastruktur (Unleash-Server
// + eigene DB), siehe "bewusst vermeiden" im LIC-02-Ticket.
package flag
import (
"context"
"errors"
"fmt"
"hash/fnv"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
var ErrNotFound = errors.New("flag: nicht gefunden")
// Flag ist die zentrale Definition — Auswertung (Evaluate) ist bewusst davon
// getrennt (Unleash-Prinzip: Flag-Verwaltung vs. Flag-Auswertung).
type Flag struct {
Key string
Enabled bool
RolloutPercentage int
TargetTenantSlugs []string
}
// Store ist die Verwaltungsseite (Admin): Flags definieren/lesen.
type Store struct {
pool *pgxpool.Pool
}
func NewStore(pool *pgxpool.Pool) *Store {
return &Store{pool: pool}
}
func (s *Store) Set(ctx context.Context, f Flag) error {
if f.TargetTenantSlugs == nil {
f.TargetTenantSlugs = []string{} // pgx uebertraegt ein nil-Slice sonst als SQL NULL statt leerem Array.
}
_, err := s.pool.Exec(ctx, `
INSERT INTO feature_flags (key, enabled, rollout_percentage, target_tenant_slugs, updated_at)
VALUES ($1, $2, $3, $4, now())
ON CONFLICT (key) DO UPDATE SET
enabled = $2, rollout_percentage = $3, target_tenant_slugs = $4, updated_at = now()
`, f.Key, f.Enabled, f.RolloutPercentage, f.TargetTenantSlugs)
if err != nil {
return fmt.Errorf("flag speichern: %w", err)
}
return nil
}
func (s *Store) Get(ctx context.Context, key string) (Flag, error) {
var f Flag
row := s.pool.QueryRow(ctx, `
SELECT key, enabled, rollout_percentage, target_tenant_slugs
FROM feature_flags WHERE key = $1
`, key)
if err := row.Scan(&f.Key, &f.Enabled, &f.RolloutPercentage, &f.TargetTenantSlugs); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Flag{}, ErrNotFound
}
return Flag{}, fmt.Errorf("flag lesen: %w", err)
}
return f, nil
}
// evaluate wendet die Strategien in fester Reihenfolge an: globaler
// An/Aus-Schalter zuerst, dann Tenant-Zielgruppe, dann Prozentsatz-Rollout.
// Ein unbekannter/nicht getroffener Fall ergibt false — Fail-Safe-Default,
// kein Feature wird versehentlich aktiv.
func evaluate(f Flag, tenantSlug string) bool {
if f.Enabled {
return true
}
for _, target := range f.TargetTenantSlugs {
if target == tenantSlug {
return true
}
}
if f.RolloutPercentage > 0 {
h := fnv.New32a()
_, _ = h.Write([]byte(f.Key + "|" + tenantSlug))
return int(h.Sum32()%100) < f.RolloutPercentage
}
return false
}
-43
View File
@@ -1,43 +0,0 @@
package flag
import "testing"
func TestEvaluate_GlobalEnabled(t *testing.T) {
f := Flag{Key: "k", Enabled: true}
if !evaluate(f, "irgendein-tenant") {
t.Fatal("global aktiviertes flag sollte fuer jeden tenant true liefern")
}
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie.
func TestEvaluate_TargetTenantStrategy(t *testing.T) {
f := Flag{Key: "k", Enabled: false, TargetTenantSlugs: []string{"acme"}}
if !evaluate(f, "acme") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if evaluate(f, "globex") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
func TestEvaluate_RolloutPercentageBoundaries(t *testing.T) {
full := Flag{Key: "k", RolloutPercentage: 100}
if !evaluate(full, "beliebiger-tenant-1") || !evaluate(full, "beliebiger-tenant-2") {
t.Fatal("100% rollout sollte immer true liefern")
}
none := Flag{Key: "k", RolloutPercentage: 0}
if evaluate(none, "beliebiger-tenant") {
t.Fatal("0% rollout ohne enabled/zielgruppe sollte false liefern")
}
}
func TestEvaluate_RolloutIsDeterministicPerTenant(t *testing.T) {
f := Flag{Key: "k", RolloutPercentage: 50}
first := evaluate(f, "stabiler-tenant")
for i := 0; i < 5; i++ {
if evaluate(f, "stabiler-tenant") != first {
t.Fatal("rollout-auswertung sollte fuer denselben tenant/key stabil sein")
}
}
}
-87
View File
@@ -1,87 +0,0 @@
package flag
import (
"context"
"log/slog"
"sync"
"time"
)
// DefaultCacheTTL ist die dokumentierte Cache-Invalidierungszeit
// (Akzeptanzkriterium 2/3): eine Aenderung wirkt spaetestens nach dieser
// Zeit auf allen Core-Instanzen, ohne dass ein Dienst neu gestartet werden
// muss (Akzeptanzkriterium 3).
const DefaultCacheTTL = 5 * time.Second
type cacheEntry struct {
flag Flag
expiresAt time.Time
}
// Service ist die Auswertungsseite (SDK/Client-Analogon zu Unleash) mit
// lokalem TTL-Cache. Bewusst getrennt von Store (Verwaltung).
type Service struct {
store *Store
ttl time.Duration
mu sync.RWMutex
cache map[string]cacheEntry
}
func NewService(store *Store, ttl time.Duration) *Service {
if ttl <= 0 {
ttl = DefaultCacheTTL
}
return &Service{store: store, ttl: ttl, cache: make(map[string]cacheEntry)}
}
// IsEnabled wertet ein Flag fuer einen Tenant aus. Liefert IMMER einen
// bool ohne Fehlerwert — ein nicht erreichbarer Flag-Dienst darf abhaengige
// Aufrufer nicht zum Absturz bringen oder zu Fehlerbehandlungscode zwingen,
// der leicht vergessen wird (Akzeptanzkriterium 3 / Pruefung 3: dokumentiertes
// Fallback-Verhalten = false, ggf. aus dem zuletzt bekannten Zwischenspeicher).
func (s *Service) IsEnabled(ctx context.Context, tenantSlug, key string) bool {
f, ok := s.resolve(ctx, key)
if !ok {
return false
}
return evaluate(f, tenantSlug)
}
func (s *Service) resolve(ctx context.Context, key string) (Flag, bool) {
s.mu.RLock()
entry, exists := s.cache[key]
fresh := exists && time.Now().Before(entry.expiresAt)
s.mu.RUnlock()
if fresh {
return entry.flag, true
}
f, err := s.store.Get(ctx, key)
if err != nil {
if exists {
slog.Warn("feature-flag-dienst nicht erreichbar, nutze zwischengespeicherten stand",
"flag_key", key, "error", err)
return entry.flag, true
}
slog.Warn("feature-flag-dienst nicht erreichbar, kein zwischengespeicherter stand vorhanden, fallback: deaktiviert",
"flag_key", key, "error", err)
return Flag{}, false
}
s.mu.Lock()
s.cache[key] = cacheEntry{flag: f, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return f, true
}
// Invalidate erzwingt beim naechsten IsEnabled-Aufruf ein sofortiges Neuladen
// aus der Datenbank statt auf den TTL-Ablauf zu warten — wird nach Store.Set
// auf derselben Instanz aufgerufen, damit der Schreiber die eigene Aenderung
// ohne Wartezeit sieht. Andere Core-Instanzen sehen sie spaetestens nach
// DefaultCacheTTL (siehe Akzeptanzkriterium 3).
func (s *Service) Invalidate(key string) {
s.mu.Lock()
delete(s.cache, key)
s.mu.Unlock()
}
-179
View File
@@ -1,179 +0,0 @@
package flag
import (
"context"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupFlagStoreTest(t *testing.T) (*Store, 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 feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0,
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() {
_, _ = pool.Exec(ctx, `DELETE FROM feature_flags WHERE key LIKE 'test\_%' ESCAPE '\'`)
pool.Close()
}
return NewStore(pool), cleanup
}
// Akzeptanzkriterium 1 + Pruefung 2: Zielgruppen-Strategie liefert im Test
// die erwartete Auswertung.
func TestService_TargetTenantStrategy(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_target_flag", TargetTenantSlugs: []string{"acme"}}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
if !svc.IsEnabled(ctx, "acme", "test_target_flag") {
t.Fatal("erwartet true fuer tenant in zielgruppe")
}
if svc.IsEnabled(ctx, "globex", "test_target_flag") {
t.Fatal("erwartet false fuer tenant ausserhalb der zielgruppe")
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 1: Flag-Aenderung wirkt innerhalb der
// dokumentierten Cache-Invalidierungszeit, automatisiert gemessen.
func TestService_CacheInvalidationTiming(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
const ttl = 150 * time.Millisecond
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, ttl)
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("erwartet false vor der aenderung")
}
// Aenderung "auf einer anderen instanz" simulieren: direkt ueber den
// Store, ohne svc.Invalidate aufzurufen.
changedAt := time.Now()
if err := store.Set(ctx, Flag{Key: "test_ttl_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
// Sofort danach sollte der Cache noch den alten Stand liefern.
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
t.Fatal("cache haette den alten (false) stand liefern sollen, direkt nach der aenderung")
}
deadline := changedAt.Add(ttl + 100*time.Millisecond)
for time.Now().Before(deadline) {
if svc.IsEnabled(ctx, "acme", "test_ttl_flag") {
elapsed := time.Since(changedAt)
t.Logf("aenderung wurde nach %s wirksam (ziel: innerhalb %s + toleranz)", elapsed, ttl)
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("aenderung wurde nicht innerhalb von %s wirksam", deadline.Sub(changedAt))
}
func TestService_InvalidateForcesImmediateRefresh(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: false}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour) // lange TTL, damit Invalidate den unterschied macht
_ = svc.IsEnabled(ctx, "acme", "test_invalidate_flag")
if err := store.Set(ctx, Flag{Key: "test_invalidate_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc.Invalidate("test_invalidate_flag")
if !svc.IsEnabled(ctx, "acme", "test_invalidate_flag") {
t.Fatal("erwartet sofort sichtbaren neuen stand nach Invalidate")
}
}
// Akzeptanzkriterium 3 + Pruefung 3: Ausfall des Flag-Dienstes fuehrt zu
// dokumentiertem Fallback-Verhalten, nicht zum Absturz.
func TestService_FallsBackOnStoreFailure(t *testing.T) {
store, cleanup := setupFlagStoreTest(t)
defer cleanup()
ctx := context.Background()
if err := store.Set(ctx, Flag{Key: "test_fallback_flag", Enabled: true}); err != nil {
t.Fatalf("set: %v", err)
}
svc := NewService(store, time.Hour)
// Cache vorwaermen, waehrend die DB noch erreichbar ist.
if !svc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet true bei funktionierender db")
}
brokenPool, err := pgxpool.New(ctx, "postgresql://nonexistent-host-fuer-test:5432/x?connect_timeout=1")
if err != nil {
t.Fatalf("broken pool erstellen (sollte nicht sofort verbinden): %v", err)
}
brokenStore := NewStore(brokenPool)
svcWithCache := NewService(brokenStore, time.Nanosecond) // TTL sofort abgelaufen, erzwingt reload-versuch
svcWithCache.mu.Lock()
svcWithCache.cache["test_fallback_flag"] = cacheEntry{
flag: Flag{Key: "test_fallback_flag", Enabled: true},
expiresAt: time.Now().Add(-time.Hour), // bereits abgelaufen
}
svcWithCache.mu.Unlock()
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict statt einen fallback zu liefern: %v", r)
}
}()
if !svcWithCache.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fallback auf zwischengespeicherten (true) stand bei db-ausfall")
}
}()
// Voellig frischer Dienst ohne jeglichen cache + kaputte db -> sicherer
// default false, kein absturz.
freshSvc := NewService(brokenStore, time.Hour)
func() {
defer func() {
if r := recover(); r != nil {
t.Fatalf("IsEnabled hat gepanict: %v", r)
}
}()
if freshSvc.IsEnabled(ctx, "acme", "test_fallback_flag") {
t.Fatal("erwartet fail-safe false ohne cache und mit kaputter db")
}
}()
}
-25
View File
@@ -1,25 +0,0 @@
package health
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
)
// DatabaseChecker prueft die tatsaechliche Erreichbarkeit der Datenbank
// (Ping) — nicht nur, ob der Pool existiert.
func DatabaseChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
return pool.Ping(ctx)
}
}
// QueueChecker prueft, dass die Postgres-basierte Job-Queue (siehe CFG-02)
// tatsaechlich abfragbar ist — eine eigene, benannte Abhaengigkeit neben der
// reinen DB-Erreichbarkeit (Akzeptanzkriterium 1).
func QueueChecker(pool *pgxpool.Pool) CheckerFunc {
return func(ctx context.Context) error {
_, err := pool.Exec(ctx, `SELECT 1`)
return err
}
}
-49
View File
@@ -1,49 +0,0 @@
package health
import (
"encoding/json"
"net/http"
)
// LivenessHandler beantwortet IMMER "lebt", solange der Prozess ueberhaupt
// HTTP-Anfragen verarbeiten kann — prueft bewusst KEINE externen
// Abhaengigkeiten (Akzeptanzkriterium 2: Liveness und Readiness getrennt).
// Ein Datenbankausfall darf die Liveness nicht auf "tot" setzen, sonst
// wuerde eine Orchestrierung (z.B. systemd/Kubernetes) den Prozess grundlos
// neu starten, obwohl nur eine Abhaengigkeit ausgefallen ist.
func LivenessHandler(w http.ResponseWriter, r *http.Request) {
writeStatus(w, http.StatusOK, map[string]any{"status": "alive"})
}
// ReadinessHandler prueft ALLE registrierten Abhaengigkeiten
// (Akzeptanzkriterium 1) und liefert 503, sobald eine davon fehlschlaegt
// (Akzeptanzkriterium 3) — unterscheidet sich damit nachweislich von
// LivenessHandler im Fehlerfall (Akzeptanzkriterium 2 / Pruefung 3).
func (r *Registry) ReadinessHandler() http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
ready, results := r.CheckAll(req.Context())
body := map[string]any{
"status": statusText(ready),
"checks": results,
}
status := http.StatusOK
if !ready {
status = http.StatusServiceUnavailable
}
writeStatus(w, status, body)
}
}
func statusText(ready bool) string {
if ready {
return "ready"
}
return "not_ready"
}
func writeStatus(w http.ResponseWriter, status int, body map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
-86
View File
@@ -1,86 +0,0 @@
// Package health implementiert Core OPS-01: Health-/Readiness-Endpunkte, die
// echte Abhaengigkeiten (DB, Job-Queue) statt nur den Prozessstatus pruefen
// — wiederverwendbar von Core UND jedem registrierten Modul (siehe API-02),
// nicht nur von Core selbst.
package health
import (
"context"
"time"
)
// Checker prueft EINE Abhaengigkeit (z.B. Datenbank, Job-Queue).
type Checker interface {
Check(ctx context.Context) error
}
type CheckerFunc func(ctx context.Context) error
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
// DefaultCheckTimeout begrenzt, wie lange EIN einzelner Check maximal
// dauern darf, bevor er als fehlgeschlagen gilt — verhindert, dass ein
// haengender Check den gesamten Readiness-Endpunkt blockiert
// (Akzeptanzkriterium 2 / Pruefung 2: Antwort innerhalb definierter Zeit).
const DefaultCheckTimeout = 2 * time.Second
// Registry haelt alle benannten Checks eines Dienstes.
type Registry struct {
checks map[string]Checker
timeout time.Duration
}
func NewRegistry() *Registry {
return &Registry{checks: make(map[string]Checker), timeout: DefaultCheckTimeout}
}
func (r *Registry) WithTimeout(d time.Duration) *Registry {
return &Registry{checks: r.checks, timeout: d}
}
// Register fuegt einen benannten Check hinzu (z.B. "database", "queue").
func (r *Registry) Register(name string, c Checker) {
r.checks[name] = c
}
// Result ist der Ausgang eines einzelnen Checks.
type Result struct {
OK bool
Error string
}
// CheckAll fuehrt alle registrierten Checks NEBENLAEUFIG mit je eigenem
// Timeout aus (Akzeptanzkriterium 1: echte Abhaengigkeiten statt Prozess-
// status) und liefert ready=false, sobald irgendein Check fehlschlaegt
// (Akzeptanzkriterium 3: ein Ausfall wird sichtbar).
func (r *Registry) CheckAll(ctx context.Context) (ready bool, results map[string]Result) {
type namedResult struct {
name string
result Result
}
ch := make(chan namedResult, len(r.checks))
for name, checker := range r.checks {
go func(name string, checker Checker) {
checkCtx, cancel := context.WithTimeout(ctx, r.timeout)
defer cancel()
err := checker.Check(checkCtx)
if err != nil {
ch <- namedResult{name, Result{OK: false, Error: err.Error()}}
return
}
ch <- namedResult{name, Result{OK: true}}
}(name, checker)
}
results = make(map[string]Result, len(r.checks))
ready = true
for i := 0; i < len(r.checks); i++ {
nr := <-ch
results[nr.name] = nr.result
if !nr.result.OK {
ready = false
}
}
return ready, results
}
-161
View File
@@ -1,161 +0,0 @@
package health
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Akzeptanzkriterium 1 + Pruefung 1: simulierter Datenbankausfall fuehrt zu
// "nicht bereit".
func TestReadinessHandler_ReportsNotReadyOnDatabaseFailure(t *testing.T) {
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)
}
// Datenbankausfall simulieren: Pool sofort schliessen, bevor der Check laeuft.
pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 bei db-ausfall", rec.Code)
}
var body struct {
Status string `json:"status"`
Checks map[string]interface{} `json:"checks"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body parsen: %v", err)
}
if body.Status != "not_ready" {
t.Fatalf("status-feld = %q, want not_ready", body.Status)
}
if _, ok := body.Checks["database"]; !ok {
t.Fatal("erwartet 'database' im checks-ergebnis")
}
}
func TestReadinessHandler_ReportsReadyWhenAllChecksPass(t *testing.T) {
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)
}
defer pool.Close()
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
reg.Register("queue", QueueChecker(pool))
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 bei funktionierenden abhaengigkeiten", rec.Code)
}
}
// Akzeptanzkriterium 2 + Pruefung 3: Liveness und Readiness unterscheiden
// sich nachweislich im Fehlerfall.
func TestLivenessAndReadiness_DifferOnDatabaseFailure(t *testing.T) {
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)
}
pool.Close() // db-ausfall simulieren
reg := NewRegistry()
reg.Register("database", DatabaseChecker(pool))
livenessRec := httptest.NewRecorder()
LivenessHandler(livenessRec, httptest.NewRequest(http.MethodGet, "/livez", nil))
if livenessRec.Code != http.StatusOK {
t.Fatalf("liveness status = %d, want 200 trotz db-ausfall (liveness prueft keine abhaengigkeiten)", livenessRec.Code)
}
readinessRec := httptest.NewRecorder()
reg.ReadinessHandler()(readinessRec, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if readinessRec.Code != http.StatusServiceUnavailable {
t.Fatalf("readiness status = %d, want 503 bei db-ausfall", readinessRec.Code)
}
if livenessRec.Code == readinessRec.Code {
t.Fatal("liveness und readiness sollten sich im db-ausfall-fall unterscheiden")
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Health-Endpunkt antwortet auch bei
// haengendem Check innerhalb definierter Zeit (Timeout begrenzt die Dauer).
func TestReadinessHandler_RespondsWithinTimeoutEvenWithHangingCheck(t *testing.T) {
reg := NewRegistry().WithTimeout(50 * time.Millisecond)
reg.Register("haengender_dienst", CheckerFunc(func(ctx context.Context) error {
select {
case <-time.After(10 * time.Second): // wuerde ohne timeout ewig blockieren
return nil
case <-ctx.Done():
return ctx.Err()
}
}))
start := time.Now()
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
reg.ReadinessHandler()(rec, req)
elapsed := time.Since(start)
if elapsed > time.Second {
t.Fatalf("readiness handler brauchte %s, erwartet deutlich unter 1s durch timeout", elapsed)
}
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503 fuer haengenden/timeout-check", rec.Code)
}
}
func TestCheckAll_MultipleChecksRunConcurrently(t *testing.T) {
reg := NewRegistry().WithTimeout(time.Second)
reg.Register("a", CheckerFunc(func(ctx context.Context) error { return nil }))
reg.Register("b", CheckerFunc(func(ctx context.Context) error { return errors.New("kaputt") }))
ready, results := reg.CheckAll(context.Background())
if ready {
t.Fatal("erwartet ready=false, da 'b' fehlschlaegt")
}
if !results["a"].OK {
t.Fatalf("erwartet 'a' ok, habe %+v", results["a"])
}
if results["b"].OK || results["b"].Error == "" {
t.Fatalf("erwartet 'b' fehlgeschlagen mit fehlertext, habe %+v", results["b"])
}
}
-19
View File
@@ -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
}
-168
View File
@@ -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
}
}
}
}
-179
View File
@@ -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)
}
}
-52
View File
@@ -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()
}
-60
View File
@@ -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)
}
}
-99
View File
@@ -1,99 +0,0 @@
package moduleregistry
import (
"context"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
)
var (
ErrModuleNotRegistered = errors.New("moduleregistry: modul muss vor provisionierung registriert sein")
ErrInvalidCredential = errors.New("moduleregistry: ungueltiges oder fehlendes service-credential")
)
// Provision stellt ein Service-Credential (Client-ID + Secret) fuer eine
// Modul-Instanz aus (Akzeptanzkriterium 4). Das Secret wird NUR beim
// Ausstellen im Klartext zurueckgegeben, gespeichert wird ausschliesslich
// dessen SHA-256-Hash.
func (r *Registry) Provision(ctx context.Context, moduleName string) (clientID, secret string, err error) {
if _, err := r.Get(ctx, moduleName); err != nil {
if errors.Is(err, ErrModuleNotFound) {
return "", "", ErrModuleNotRegistered
}
return "", "", err
}
clientID, err = randomToken(16)
if err != nil {
return "", "", fmt.Errorf("client-id erzeugen: %w", err)
}
secret, err = randomToken(32)
if err != nil {
return "", "", fmt.Errorf("secret erzeugen: %w", err)
}
hash := hashSecret(secret)
_, err = r.pool.Exec(ctx, `
INSERT INTO module_credentials (module_name, client_id, secret_hash, issued_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (module_name) DO UPDATE SET client_id = $2, secret_hash = $3, issued_at = now()
`, moduleName, clientID, hash)
if err != nil {
return "", "", fmt.Errorf("credential speichern: %w", err)
}
return clientID, secret, nil
}
// Authenticate prueft ein Service-Credential timing-safe (Referenzmuster
// siehe AUD-02) — Aufrufe ohne gueltiges Credential werden abgelehnt
// (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) Authenticate(ctx context.Context, clientID, secret string) (moduleName string, ok bool, err error) {
if clientID == "" || secret == "" {
return "", false, nil
}
var storedHash []byte
err = r.pool.QueryRow(ctx, `
SELECT module_name, secret_hash FROM module_credentials WHERE client_id = $1
`, clientID).Scan(&moduleName, &storedHash)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return "", false, nil
}
return "", false, fmt.Errorf("credential lesen: %w", err)
}
if !timingSafeEqual(hashSecret(secret), storedHash) {
return "", false, nil
}
return moduleName, true, nil
}
func randomToken(n int) (string, error) {
buf := make([]byte, n)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func hashSecret(secret string) []byte {
sum := sha256.Sum256([]byte(secret))
return sum[:]
}
// timingSafeEqual folgt derselben Referenzimplementierung wie AUD-02
// (subtle.ConstantTimeCompare) — projektweite Konvention fuer jeden
// sicherheitsrelevanten Vergleich.
func timingSafeEqual(a, b []byte) bool {
if len(a) != len(b) {
return false
}
return subtle.ConstantTimeCompare(a, b) == 1
}
-46
View File
@@ -1,46 +0,0 @@
package moduleregistry
import "net/http"
// RequireActiveModule weist Anfragen an ein nicht aktiviertes Modul ZENTRAL
// ab, bevor der eigentliche Modul-Handler erreicht wird (Akzeptanzkriterium 2 /
// Pruefung 1) — Casbin-Prinzip: Durchsetzung als Middleware statt verstreuter
// Pruefungen in jedem Handler. tenantSlug/moduleName werden hier ueber
// Query-Parameter gelesen (echte Extraktion aus JWT/Tenant-Kontext ist
// API-05/TEN-06, nicht Teil dieser Kachel).
func (r *Registry) RequireActiveModule(moduleName string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
tenantSlug := req.URL.Query().Get("tenant")
active, err := r.IsActive(req.Context(), tenantSlug, moduleName)
if err != nil {
http.Error(w, "aktivierungspruefung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !active {
http.Error(w, "modul nicht aktiviert", http.StatusForbidden)
return
}
next(w, req)
}
}
// RequireServiceCredential authentifiziert eine Modul-Instanz ueber ihr
// Service-Credential (X-Client-Id/X-Client-Secret-Header) BEVOR der
// eigentliche Handler erreicht wird (Akzeptanzkriterium 4 / Pruefung 4).
func (r *Registry) RequireServiceCredential(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
clientID := req.Header.Get("X-Client-Id")
secret := req.Header.Get("X-Client-Secret")
_, ok, err := r.Authenticate(req.Context(), clientID, secret)
if err != nil {
http.Error(w, "authentifizierung fehlgeschlagen", http.StatusInternalServerError)
return
}
if !ok {
http.Error(w, ErrInvalidCredential.Error(), http.StatusUnauthorized)
return
}
next(w, req)
}
}
-120
View File
@@ -1,120 +0,0 @@
// Package moduleregistry implementiert Core API-02: die Registry, in der
// sich Fachmodule (DMS, Mail, weitere) mit Metadaten eintragen, gekoppelt an
// die Aktivierungspruefung aus LIC-02 (Feature-Flags). Zusaetzlich
// authentifiziert die Registry Modul-Instanzen selbst ueber ein bei
// Provisionierung ausgestelltes Service-Credential.
package moduleregistry
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
var (
ErrMissingName = errors.New("moduleregistry: name darf nicht leer sein")
ErrMissingVersion = errors.New("moduleregistry: version darf nicht leer sein")
ErrModuleNotFound = errors.New("moduleregistry: modul nicht registriert")
)
type Module struct {
Name string
Version string
RequiredFlags []string
}
type Registry struct {
pool *pgxpool.Pool
flags *flag.Service
}
func NewRegistry(pool *pgxpool.Pool, flags *flag.Service) *Registry {
return &Registry{pool: pool, flags: flags}
}
// Register traegt ein Modul mit Name, Version und benoetigten Feature-Flags
// ein (Akzeptanzkriterium 1). Fehlende Pflichtangaben werden abgewiesen
// (Akzeptanzkriterium 1 / Pruefung 2). Erneutes Register desselben Namens
// aktualisiert Version/Flags (Redeploy-Fall).
func (r *Registry) Register(ctx context.Context, name, version string, requiredFlags []string) (Module, error) {
if name == "" {
return Module{}, ErrMissingName
}
if version == "" {
return Module{}, ErrMissingVersion
}
if requiredFlags == nil {
requiredFlags = []string{}
}
_, err := r.pool.Exec(ctx, `
INSERT INTO modules (name, version, required_flags, registered_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (name) DO UPDATE SET version = $2, required_flags = $3, registered_at = now()
`, name, version, requiredFlags)
if err != nil {
return Module{}, fmt.Errorf("modul registrieren: %w", err)
}
return Module{Name: name, Version: version, RequiredFlags: requiredFlags}, nil
}
func (r *Registry) Get(ctx context.Context, name string) (Module, error) {
var m Module
m.Name = name
err := r.pool.QueryRow(ctx, `
SELECT version, required_flags FROM modules WHERE name = $1
`, name).Scan(&m.Version, &m.RequiredFlags)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return Module{}, ErrModuleNotFound
}
return Module{}, fmt.Errorf("modul lesen: %w", err)
}
return m, nil
}
// List liefert alle registrierten Module (Akzeptanzkriterium 3: ueber API
// abfragbar, z.B. fuer Statusseite/Lizenzoberflaeche).
func (r *Registry) List(ctx context.Context) ([]Module, error) {
rows, err := r.pool.Query(ctx, `SELECT name, version, required_flags FROM modules ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("module auflisten: %w", err)
}
defer rows.Close()
var out []Module
for rows.Next() {
var m Module
if err := rows.Scan(&m.Name, &m.Version, &m.RequiredFlags); err != nil {
return nil, fmt.Errorf("modul lesen: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// IsActive prueft, ob ein registriertes Modul fuer einen Tenant aktiviert
// ist: registriert UND alle benoetigten Feature-Flags sind fuer diesen
// Tenant aktiv (Akzeptanzkriterium 2). Ein nicht registriertes Modul gilt
// immer als nicht aktiv.
func (r *Registry) IsActive(ctx context.Context, tenantSlug, moduleName string) (bool, error) {
m, err := r.Get(ctx, moduleName)
if err != nil {
if errors.Is(err, ErrModuleNotFound) {
return false, nil
}
return false, err
}
for _, flagKey := range m.RequiredFlags {
if !r.flags.IsEnabled(ctx, tenantSlug, flagKey) {
return false, nil
}
}
return true, nil
}
-304
View File
@@ -1,304 +0,0 @@
package moduleregistry
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"gitea.perlbach24.de/scripte/nexarch/internal/flag"
)
func setupTest(t *testing.T) (*Registry, *flag.Store, 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 feature_flags (
key TEXT PRIMARY KEY, enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0, target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS modules (
name TEXT PRIMARY KEY, version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}', registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE, secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
flagStore := flag.NewStore(pool)
// Kurze TTL, damit Tests, die den Flag-Store direkt aendern (an
// Registry.IsActive vorbei), den neuen Stand ohne manuelles Invalidate
// zuverlaessig sehen.
flagService := flag.NewService(flagStore, 10*time.Millisecond)
registry := NewRegistry(pool, flagService)
cleanup := func() { pool.Close() }
return registry, flagStore, cleanup
}
func uniqueModuleName(t *testing.T) string {
return fmt.Sprintf("dms_%d", time.Now().UnixNano())
}
// Akzeptanzkriterium 1 + Pruefung 2: fehlende Pflichtangaben abgewiesen.
func TestRegister_RejectsMissingFields(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, err := registry.Register(ctx, "", "1.0", nil); !errors.Is(err, ErrMissingName) {
t.Fatalf("erwartet ErrMissingName, habe %v", err)
}
if _, err := registry.Register(ctx, "dms", "", nil); !errors.Is(err, ErrMissingVersion) {
t.Fatalf("erwartet ErrMissingVersion, habe %v", err)
}
}
func TestRegister_AndGet(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
m, err := registry.Register(ctx, name, "1.2.0", []string{"dms_enabled"})
if err != nil {
t.Fatalf("register: %v", err)
}
if m.Version != "1.2.0" || len(m.RequiredFlags) != 1 {
t.Fatalf("unerwartet: %+v", m)
}
got, err := registry.Get(ctx, name)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Version != "1.2.0" {
t.Fatalf("get version = %q", got.Version)
}
}
// Akzeptanzkriterium 2 + 3 + Pruefung 3: konsistente Daten nach
// Aktivierung/Deaktivierung eines Moduls.
func TestIsActive_ReflectsFlagStateConsistently(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
active, err := registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (vor flag): %v", err)
}
if active {
t.Fatal("erwartet nicht aktiv, solange flag nicht gesetzt ist")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag an): %v", err)
}
if !active {
t.Fatal("erwartet aktiv, nachdem flag aktiviert wurde")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: false}); err != nil {
t.Fatalf("flag zuruecksetzen: %v", err)
}
time.Sleep(20 * time.Millisecond) // TTL abwarten
active, err = registry.IsActive(ctx, "acme", name)
if err != nil {
t.Fatalf("is active (nach flag aus): %v", err)
}
if active {
t.Fatal("erwartet wieder nicht aktiv, nachdem flag deaktiviert wurde")
}
}
func TestIsActive_UnregisteredModuleIsNeverActive(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
active, err := registry.IsActive(ctx, "acme", "nie-registriert")
if err != nil {
t.Fatalf("is active: %v", err)
}
if active {
t.Fatal("unregistriertes modul darf nie aktiv sein")
}
}
// Akzeptanzkriterium 2 + Pruefung 1: Anfrage an deaktiviertes Modul wird
// zentral abgewiesen, BEVOR die Modul-Logik erreicht wird.
func TestRequireActiveModule_BlocksBeforeHandler(t *testing.T) {
registry, flagStore, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
flagKey := name + "_enabled"
if _, err := registry.Register(ctx, name, "1.0", []string{flagKey}); err != nil {
t.Fatalf("register: %v", err)
}
handlerReached := false
handler := registry.RequireActiveModule(name, func(w http.ResponseWriter, r *http.Request) {
handlerReached = true
w.WriteHeader(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if handlerReached {
t.Fatal("handler haette bei deaktiviertem modul NICHT erreicht werden duerfen")
}
if err := flagStore.Set(ctx, flag.Flag{Key: flagKey, Enabled: true}); err != nil {
t.Fatalf("flag setzen: %v", err)
}
req2 := httptest.NewRequest(http.MethodGet, "/modul?tenant=acme", nil)
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusOK {
t.Fatalf("status nach aktivierung = %d, want 200", rec2.Code)
}
if !handlerReached {
t.Fatal("handler haette bei aktiviertem modul erreicht werden muessen")
}
}
// Akzeptanzkriterium 4 + Pruefung 4: gueltiges/ungueltiges Service-Credential.
func TestProvisionAndAuthenticate(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
if clientID == "" || secret == "" {
t.Fatal("erwartet nicht-leere client-id/secret")
}
moduleName, ok, err := registry.Authenticate(ctx, clientID, secret)
if err != nil {
t.Fatalf("authenticate (korrekt): %v", err)
}
if !ok || moduleName != name {
t.Fatalf("erwartet erfolgreiche authentifizierung fuer %q, habe ok=%v moduleName=%q", name, ok, moduleName)
}
_, ok, err = registry.Authenticate(ctx, clientID, "falsches-secret")
if err != nil {
t.Fatalf("authenticate (falsch): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei falschem secret")
}
_, ok, err = registry.Authenticate(ctx, "unbekannte-client-id", secret)
if err != nil {
t.Fatalf("authenticate (unbekannt): %v", err)
}
if ok {
t.Fatal("erwartet fehlschlag bei unbekannter client-id")
}
}
func TestProvision_RequiresRegisteredModule(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
if _, _, err := registry.Provision(ctx, "nie-registriert"); !errors.Is(err, ErrModuleNotRegistered) {
t.Fatalf("erwartet ErrModuleNotRegistered, habe %v", err)
}
}
func TestRequireServiceCredential_RejectsInvalidAcceptsValid(t *testing.T) {
registry, _, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
name := uniqueModuleName(t)
if _, err := registry.Register(ctx, name, "1.0", nil); err != nil {
t.Fatalf("register: %v", err)
}
clientID, secret, err := registry.Provision(ctx, name)
if err != nil {
t.Fatalf("provision: %v", err)
}
handler := registry.RequireServiceCredential(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Fehlendes Credential.
req := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
rec := httptest.NewRecorder()
handler(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("ohne credential: status = %d, want 401", rec.Code)
}
// Falsches Secret.
req2 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req2.Header.Set("X-Client-Id", clientID)
req2.Header.Set("X-Client-Secret", "falsch")
rec2 := httptest.NewRecorder()
handler(rec2, req2)
if rec2.Code != http.StatusUnauthorized {
t.Fatalf("falsches secret: status = %d, want 401", rec2.Code)
}
// Gueltiges Credential.
req3 := httptest.NewRequest(http.MethodPost, "/service-aufruf", nil)
req3.Header.Set("X-Client-Id", clientID)
req3.Header.Set("X-Client-Secret", secret)
rec3 := httptest.NewRecorder()
handler(rec3, req3)
if rec3.Code != http.StatusOK {
t.Fatalf("gueltiges credential: status = %d, want 200", rec3.Code)
}
}
-81
View File
@@ -1,81 +0,0 @@
// Package notify implementiert Core CFG-02: den zentralen Benachrichtigungs-
// Dispatcher, ueber den beliebige Module Benachrichtigungen ausloesen —
// Warteschlange, Wiederholungslogik, Kanal-Abstraktion. Die tatsaechlichen
// Kanaele (E-Mail/In-App) sind CFG-03, hier gibt es nur die Sender-
// Schnittstelle als Vorbereitung.
package notify
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// DefaultMaxAttempts begrenzt Wiederholungsversuche (Akzeptanzkriterium 2) —
// nach dieser Anzahl gibt der Dispatcher kontrolliert auf (status=failed)
// statt endlos zu wiederholen.
const DefaultMaxAttempts = 5
// DefaultRetryBackoff ist die Basis-Wartezeit zwischen Wiederholungen,
// linear mit der Versuchsnummer skaliert.
const DefaultRetryBackoff = 200 * time.Millisecond
type Notification struct {
ID string
Channel string
Recipient string
Payload map[string]any
Attempts int
}
// Sender ist die schmale Schnittstelle, die ein konkreter Kanal (CFG-03)
// implementiert. Der Dispatcher selbst weiss nichts ueber E-Mail/In-App.
type Sender interface {
Send(ctx context.Context, n Notification) error
}
// Dispatcher ist die EINE Schnittstelle, ueber die Module Benachrichtigungen
// ausloesen — kein Modul baut eigenen Versandcode (Akzeptanzkriterium 1).
type Dispatcher struct {
pool *pgxpool.Pool
maxAttempts int
retryBackoff time.Duration
}
func NewDispatcher(pool *pgxpool.Pool) *Dispatcher {
return &Dispatcher{pool: pool, maxAttempts: DefaultMaxAttempts, retryBackoff: DefaultRetryBackoff}
}
// WithRetryPolicy erlaubt Tests/Betrieb, Versuchsanzahl und Backoff
// anzupassen, ohne die Default-Policy im Produktionscode zu veraendern.
func (d *Dispatcher) WithRetryPolicy(maxAttempts int, backoff time.Duration) *Dispatcher {
return &Dispatcher{pool: d.pool, maxAttempts: maxAttempts, retryBackoff: backoff}
}
// Enqueue reiht eine Benachrichtigung in die Postgres-Warteschlange ein und
// kehrt sofort zurueck — die Zeile ueberlebt jeden Neustart des Dispatcher-
// Prozesses unveraendert (Akzeptanzkriterium 3), da sie ausschliesslich in
// der Datenbank existiert, nicht im Prozessspeicher.
func (d *Dispatcher) Enqueue(ctx context.Context, channel, recipient string, payload map[string]any) (string, error) {
if payload == nil {
payload = map[string]any{}
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("payload serialisieren: %w", err)
}
var id string
err = d.pool.QueryRow(ctx, `
INSERT INTO notification_jobs (channel, recipient, payload, max_attempts)
VALUES ($1, $2, $3, $4)
RETURNING id
`, channel, recipient, payloadJSON, d.maxAttempts).Scan(&id)
if err != nil {
return "", fmt.Errorf("benachrichtigung einreihen: %w", err)
}
return id, nil
}
-219
View File
@@ -1,219 +0,0 @@
package notify
import (
"context"
"errors"
"os"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) (*pgxpool.Pool, 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 notification_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel TEXT NOT NULL,
recipient TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("schema: %v", err)
}
cleanup := func() { pool.Close() }
return pool, cleanup
}
type fakeSender struct {
mu sync.Mutex
sentIDs []string
failUntil int
calls int
}
func (f *fakeSender) Send(ctx context.Context, n Notification) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
if f.calls <= f.failUntil {
return errors.New("simulierter zustellfehler")
}
f.sentIDs = append(f.sentIDs, n.ID)
return nil
}
func (f *fakeSender) sentCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return len(f.sentIDs)
}
// Akzeptanzkriterium 1: Module loesen ueber Enqueue aus, keine eigene
// Versandlogik noetig.
func TestDispatcher_EnqueueAndProcess(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
d := NewDispatcher(pool)
id, err := d.Enqueue(ctx, "email", "alice@example.com", map[string]any{"subject": "Willkommen"})
if err != nil {
t.Fatalf("enqueue: %v", err)
}
if id == "" {
t.Fatal("erwartet nicht-leere id")
}
sender := &fakeSender{}
sent, failed, err := d.ProcessDue(ctx, sender, 10)
if err != nil {
t.Fatalf("process: %v", err)
}
if sent != 1 || failed != 0 {
t.Fatalf("erwartet sent=1 failed=0, habe sent=%d failed=%d", sent, failed)
}
if sender.sentCount() != 1 {
t.Fatalf("erwartet 1 zustellung, habe %d", sender.sentCount())
}
}
// Akzeptanzkriterium 2 + Pruefung 2: Wiederholungslogik greift bei
// simuliertem Fehler und bricht nach definierter Anzahl kontrolliert ab.
func TestProcessDue_RetriesThenGivesUpAfterMaxAttempts(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
d := NewDispatcher(pool).WithRetryPolicy(3, time.Millisecond)
id, err := d.Enqueue(ctx, "email", "bob@example.com", nil)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
sender := &fakeSender{failUntil: 100} // schlaegt bei jedem versuch fehl
for i := 0; i < 3; i++ {
time.Sleep(5 * time.Millisecond) // next_attempt_at abwarten
if _, _, err := d.ProcessDue(ctx, sender, 10); err != nil {
t.Fatalf("process %d: %v", i, err)
}
}
var status string
var attempts int
if err := pool.QueryRow(ctx, `SELECT status, attempts FROM notification_jobs WHERE id = $1`, id).Scan(&status, &attempts); err != nil {
t.Fatalf("status lesen: %v", err)
}
if status != "failed" {
t.Fatalf("erwartet status failed nach max_attempts, habe %q", status)
}
if attempts != 3 {
t.Fatalf("erwartet 3 versuche, habe %d", attempts)
}
// Weiteres ProcessDue darf den bereits aufgegebenen job nicht mehr anfassen.
sent, failed, err := d.ProcessDue(ctx, sender, 10)
if err != nil {
t.Fatalf("process nach abbruch: %v", err)
}
if sent != 0 || failed != 0 {
t.Fatalf("erwartet keine weitere verarbeitung, habe sent=%d failed=%d", sent, failed)
}
}
// Akzeptanzkriterium 3 + Pruefung 1: Neustart des Dienstes waehrend offener
// Zustellung verliert keine Nachricht — simuliert durch eine komplett neue
// Dispatcher/Pool-Instanz nach dem Enqueue, bevor irgendetwas verarbeitet wurde.
func TestQueue_SurvivesRestartWithoutMessageLoss(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
firstInstance := NewDispatcher(pool)
id, err := firstInstance.Enqueue(ctx, "email", "carol@example.com", nil)
if err != nil {
t.Fatalf("enqueue: %v", err)
}
// "Neustart": eine voellig neue Dispatcher-Instanz (repraesentiert einen
// neuen Prozess) verbindet sich neu und verarbeitet die Warteschlange —
// die Nachricht existiert ausschliesslich in Postgres, nicht im
// Prozessspeicher der ersten Instanz.
restartedInstance := NewDispatcher(pool)
sender := &fakeSender{}
sent, failed, err := restartedInstance.ProcessDue(ctx, sender, 10)
if err != nil {
t.Fatalf("process nach neustart: %v", err)
}
if sent != 1 || failed != 0 {
t.Fatalf("erwartet sent=1 nach neustart, habe sent=%d failed=%d", sent, failed)
}
if len(sender.sentIDs) != 1 || sender.sentIDs[0] != id {
t.Fatalf("erwartet zustellung der urspruenglichen nachricht %q, habe %v", id, sender.sentIDs)
}
}
// Akzeptanzkriterium 3 + Pruefung 3: zwei gleichzeitig ausloesende Module,
// beide Nachrichten werden korrekt (und nicht doppelt) zugestellt.
func TestProcessDue_ConcurrentDispatchBothDelivered(t *testing.T) {
pool, cleanup := setupTest(t)
defer cleanup()
ctx := context.Background()
d := NewDispatcher(pool)
idA, err := d.Enqueue(ctx, "email", "modul-a@example.com", nil)
if err != nil {
t.Fatalf("enqueue a: %v", err)
}
idB, err := d.Enqueue(ctx, "email", "modul-b@example.com", nil)
if err != nil {
t.Fatalf("enqueue b: %v", err)
}
sender := &fakeSender{}
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, _, err := d.ProcessDue(ctx, sender, 10); err != nil {
t.Errorf("process: %v", err)
}
}()
}
wg.Wait()
if sender.sentCount() != 2 {
t.Fatalf("erwartet genau 2 zustellungen, habe %d: %v", sender.sentCount(), sender.sentIDs)
}
seen := map[string]bool{}
for _, id := range sender.sentIDs {
if seen[id] {
t.Fatalf("nachricht %q wurde doppelt zugestellt", id)
}
seen[id] = true
}
if !seen[idA] || !seen[idB] {
t.Fatalf("erwartet beide nachrichten zugestellt, habe %v", sender.sentIDs)
}
}
-102
View File
@@ -1,102 +0,0 @@
package notify
import (
"context"
"encoding/json"
"fmt"
"time"
)
// ProcessDue holt bis zu limit faellige Benachrichtigungen und versucht sie
// ueber sender zuzustellen. FOR UPDATE SKIP LOCKED serialisiert konkurrierende
// Aufrufe (Akzeptanzkriterium 3 / Pruefung 3: zwei gleichzeitig ausloesende
// Module duerfen sich nicht gegenseitig blockieren oder Nachrichten doppelt
// zustellen) — dieselbe Konvention wie internal/tenant.Lifecycle.ProcessDueDeletions.
func (d *Dispatcher) ProcessDue(ctx context.Context, sender Sender, limit int) (sent, failed int, err error) {
tx, err := d.pool.Begin(ctx)
if err != nil {
return 0, 0, fmt.Errorf("transaktion starten: %w", err)
}
defer func() { _ = tx.Rollback(ctx) }()
rows, err := tx.Query(ctx, `
SELECT id, channel, recipient, payload, attempts, max_attempts
FROM notification_jobs
WHERE status = 'pending' AND next_attempt_at <= now()
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT $1
`, limit)
if err != nil {
return 0, 0, fmt.Errorf("faellige benachrichtigungen abfragen: %w", err)
}
type due struct {
id, channel, recipient string
payload []byte
attempts, maxAttempts int
}
var candidates []due
for rows.Next() {
var c due
if err := rows.Scan(&c.id, &c.channel, &c.recipient, &c.payload, &c.attempts, &c.maxAttempts); err != nil {
rows.Close()
return 0, 0, fmt.Errorf("faellige benachrichtigung lesen: %w", err)
}
candidates = append(candidates, c)
}
rows.Close()
if err := rows.Err(); err != nil {
return 0, 0, err
}
for _, c := range candidates {
var payload map[string]any
if err := json.Unmarshal(c.payload, &payload); err != nil {
payload = map[string]any{}
}
sendErr := sender.Send(ctx, Notification{
ID: c.id, Channel: c.channel, Recipient: c.recipient, Payload: payload, Attempts: c.attempts,
})
if sendErr == nil {
if _, err := tx.Exec(ctx, `
UPDATE notification_jobs SET status = 'sent', updated_at = now() WHERE id = $1
`, c.id); err != nil {
return sent, failed, fmt.Errorf("erfolg speichern: %w", err)
}
sent++
continue
}
newAttempts := c.attempts + 1
if newAttempts >= c.maxAttempts {
// Akzeptanzkriterium 2: kontrollierter Abbruch nach definierter
// Anzahl Versuche, kein endloses Wiederholen.
if _, err := tx.Exec(ctx, `
UPDATE notification_jobs
SET status = 'failed', attempts = $2, last_error = $3, updated_at = now()
WHERE id = $1
`, c.id, newAttempts, sendErr.Error()); err != nil {
return sent, failed, fmt.Errorf("fehlschlag speichern: %w", err)
}
failed++
continue
}
nextAttempt := time.Now().Add(time.Duration(newAttempts) * d.retryBackoff)
if _, err := tx.Exec(ctx, `
UPDATE notification_jobs
SET attempts = $2, next_attempt_at = $3, last_error = $4, updated_at = now()
WHERE id = $1
`, c.id, newAttempts, nextAttempt, sendErr.Error()); err != nil {
return sent, failed, fmt.Errorf("wiederholung planen: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return 0, 0, fmt.Errorf("transaktion committen: %w", err)
}
return sent, failed, nil
}
-45
View File
@@ -1,45 +0,0 @@
package tenant
import (
"encoding/json"
"net/http"
)
// Handler ist eine schlanke Vorbereitung der Schnittstelle fuer API-01
// (REST-API-Grundgerüst & Versionierung) und TEN-02 (Self-Service-Onboarding).
// Auth/Rate-Limiting/Versionierung selbst sind ausdruecklich nicht Teil von
// TEN-01 und werden dort nachgezogen.
type Handler struct {
provisioner *Provisioner
}
func NewHandler(p *Provisioner) *Handler {
return &Handler{provisioner: p}
}
type createTenantRequest struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
func (h *Handler) CreateTenant(w http.ResponseWriter, r *http.Request) {
var req createTenantRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungueltige Anfrage", http.StatusBadRequest)
return
}
t, err := h.provisioner.Provision(r.Context(), req.Slug, req.Name)
if err != nil {
if err == ErrInvalidSlug {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
http.Error(w, "tenant konnte nicht angelegt werden", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(t)
}
-78
View File
@@ -1,78 +0,0 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// Provisioner legt fuer jeden neuen Mandanten eine vollstaendig isolierte
// PostgreSQL-Datenbank an und registriert sie transaktional in der Registry
// (Akzeptanzkriterium 2). Zwei Mandanten-Datenbanken sind danach auf
// Infrastrukturebene komplett getrennt (Akzeptanzkriterium 3).
type Provisioner struct {
// adminPool ist mit der Wartungsdatenbank (z. B. "postgres") verbunden
// und wird ausschliesslich fuer CREATE/DROP DATABASE verwendet, da diese
// Befehle in PostgreSQL nicht in einer Transaktion laufen koennen.
adminPool *pgxpool.Pool
registry *Registry
// dsnTemplate enthaelt genau ein "%s" als Platzhalter fuer den
// Datenbanknamen, z. B. "postgresql://user:pass@host:5432/%s?sslmode=disable".
dsnTemplate string
}
func NewProvisioner(adminPool *pgxpool.Pool, registry *Registry, dsnTemplate string) *Provisioner {
return &Provisioner{adminPool: adminPool, registry: registry, dsnTemplate: dsnTemplate}
}
// Provision legt die Tenant-Datenbank an und registriert sie. Schlaegt die
// Registrierung fehl, wird die bereits angelegte Datenbank wieder entfernt,
// damit kein verwaister, unregistrierter Tenant zurueckbleibt.
func (p *Provisioner) Provision(ctx context.Context, slug, name string) (Tenant, error) {
if err := ValidateSlug(slug); err != nil {
return Tenant{}, err
}
dbName := dbNameForSlug(slug)
// CREATE DATABASE erlaubt keine Parameter-Platzhalter; slug ist durch
// ValidateSlug bereits auf [a-z0-9_] beschraenkt, Injektion ausgeschlossen.
if _, err := p.adminPool.Exec(ctx, fmt.Sprintf(`CREATE DATABASE %q`, dbName)); err != nil {
return Tenant{}, fmt.Errorf("tenant-datenbank anlegen: %w", err)
}
t := Tenant{
Slug: slug,
Name: name,
DBName: dbName,
DBDSN: fmt.Sprintf(p.dsnTemplate, dbName),
Status: StatusActive,
}
tx, err := p.registry.pool.Begin(ctx)
if err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion starten: %w", err)
}
created, err := p.registry.insertTx(ctx, tx, t)
if err != nil {
_ = tx.Rollback(ctx)
p.rollbackDatabase(ctx, dbName)
return Tenant{}, err
}
if err := tx.Commit(ctx); err != nil {
p.rollbackDatabase(ctx, dbName)
return Tenant{}, fmt.Errorf("registry-transaktion committen: %w", err)
}
return created, nil
}
// rollbackDatabase entfernt eine bereits angelegte Tenant-Datenbank, wenn die
// Registrierung fehlschlug, damit Provisioning insgesamt atomar wirkt.
func (p *Provisioner) rollbackDatabase(ctx context.Context, dbName string) {
_, _ = p.adminPool.Exec(ctx, fmt.Sprintf(`DROP DATABASE IF EXISTS %q`, dbName))
}
-105
View File
@@ -1,105 +0,0 @@
package tenant
import (
"context"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// Integrationstest fuer Akzeptanzkriterien 2 und 3. Benoetigt eine echte
// Postgres-Instanz und wird ohne TEST_ADMIN_DSN uebersprungen, nicht als
// fehlgeschlagen gewertet — siehe Pruefungen-Ergebnis im PR.
//
// TEST_ADMIN_DSN muss auf die Wartungsdatenbank zeigen, z. B.:
//
// postgresql://postgres:postgres@localhost:5432/postgres?sslmode=disable
func TestProvision_CreatesIsolatedDatabases(t *testing.T) {
adminDSN := os.Getenv("TEST_ADMIN_DSN")
if adminDSN == "" {
t.Skip("TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
}
ctx := context.Background()
adminPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("admin pool: %v", err)
}
defer adminPool.Close()
registryPool, err := pgxpool.New(ctx, adminDSN)
if err != nil {
t.Fatalf("registry pool: %v", err)
}
defer registryPool.Close()
if _, err := registryPool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`); err != nil {
t.Fatalf("registry-schema: %v", err)
}
dsnTemplate := strings.Replace(adminDSN, "/postgres?", "/%s?", 1)
registry := NewRegistry(registryPool)
provisioner := NewProvisioner(adminPool, registry, dsnTemplate)
t.Cleanup(func() {
_, _ = registryPool.Exec(ctx, `DELETE FROM tenants WHERE slug IN ('acme','globex')`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_acme`)
_, _ = adminPool.Exec(ctx, `DROP DATABASE IF EXISTS tenant_globex`)
})
tenantA, err := provisioner.Provision(ctx, "acme", "Acme GmbH")
if err != nil {
t.Fatalf("provision acme: %v", err)
}
tenantB, err := provisioner.Provision(ctx, "globex", "Globex AG")
if err != nil {
t.Fatalf("provision globex: %v", err)
}
if tenantA.DBName == tenantB.DBName {
t.Fatalf("erwartet unterschiedliche db_name, beide sind %q", tenantA.DBName)
}
// Akzeptanzkriterium 3 / Pruefung 3: In der Datenbank von Tenant A existiert
// keine Verbindungsmoeglichkeit zu Tenant B, weil beide physisch getrennte
// Datenbanken sind, statt sich auf einen Query-Filter zu verlassen.
poolA, err := pgxpool.New(ctx, tenantA.DBDSN)
if err != nil {
t.Fatalf("connect tenant a: %v", err)
}
defer poolA.Close()
var globexVisible bool
err = poolA.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM pg_catalog.pg_database WHERE datname = $1
)
`, tenantB.DBName).Scan(&globexVisible)
if err != nil {
t.Fatalf("pruefung tenant-trennung: %v", err)
}
// pg_database ist clusterweit sichtbar (Existenz der DB), aber die
// eigentliche Pruefung ist: aus poolA (verbunden mit tenant_acme) ist keine
// Tabelle/Zeile aus tenant_globex erreichbar, da current_database() getrennt ist.
var currentDB string
if err := poolA.QueryRow(ctx, `SELECT current_database()`).Scan(&currentDB); err != nil {
t.Fatalf("current_database: %v", err)
}
if currentDB != tenantA.DBName {
t.Fatalf("current_database() = %q, want %q — keine physische Trennung", currentDB, tenantA.DBName)
}
if currentDB == tenantB.DBName {
t.Fatalf("tenant a verbindung zeigt auf tenant b datenbank")
}
}
-69
View File
@@ -1,69 +0,0 @@
package tenant
import (
"context"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// Registry kapselt den Zugriff auf die Control-Plane-Registry-Datenbank.
// Sie enthaelt ausschliesslich Tenant-Metadaten (Akzeptanzkriterium 1) —
// niemals Geschaeftsdaten eines Mandanten.
type Registry struct {
pool *pgxpool.Pool
}
func NewRegistry(pool *pgxpool.Pool) *Registry {
return &Registry{pool: pool}
}
// insertTx schreibt den Tenant-Datensatz innerhalb einer laufenden Transaktion,
// damit Provisioner.Provision DB-Anlage und Registrierung atomar behandeln kann.
func (r *Registry) insertTx(ctx context.Context, tx pgx.Tx, t Tenant) (Tenant, error) {
row := tx.QueryRow(ctx, `
INSERT INTO tenants (slug, name, db_name, db_dsn, status)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, created_at
`, t.Slug, t.Name, t.DBName, t.DBDSN, t.Status)
if err := row.Scan(&t.ID, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant registrieren: %w", err)
}
return t, nil
}
func (r *Registry) GetBySlug(ctx context.Context, slug string) (Tenant, error) {
var t Tenant
row := r.pool.QueryRow(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants WHERE slug = $1
`, slug)
if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return Tenant{}, fmt.Errorf("tenant laden: %w", err)
}
return t, nil
}
func (r *Registry) List(ctx context.Context) ([]Tenant, error) {
rows, err := r.pool.Query(ctx, `
SELECT id, slug, name, db_name, db_dsn, status, created_at
FROM tenants ORDER BY created_at
`)
if err != nil {
return nil, fmt.Errorf("tenants auflisten: %w", err)
}
defer rows.Close()
var out []Tenant
for rows.Next() {
var t Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.DBName, &t.DBDSN, &t.Status, &t.CreatedAt); err != nil {
return nil, fmt.Errorf("tenant lesen: %w", err)
}
out = append(out, t)
}
return out, rows.Err()
}
-42
View File
@@ -1,42 +0,0 @@
// Package tenant implements Core TEN-01: die Control-Plane-Registry und die
// Provisioning-Routine fuer physisch getrennte Mandanten-Datenbanken (Modell C).
package tenant
import (
"errors"
"regexp"
"time"
)
type Status string
const (
StatusActive Status = "active"
)
type Tenant struct {
ID string
Slug string
Name string
DBName string
DBDSN string
Status Status
CreatedAt time.Time
}
// slugPattern erzwingt sichere, als SQL-Identifier verwendbare Slugs, damit
// der Datenbankname niemals aus unkontrolliertem Nutzereingabe-Text gebaut wird.
var slugPattern = regexp.MustCompile(`^[a-z][a-z0-9_]{1,48}$`)
var ErrInvalidSlug = errors.New("tenant: slug muss mit Kleinbuchstaben beginnen und darf nur [a-z0-9_] enthalten (2-49 Zeichen)")
func ValidateSlug(slug string) error {
if !slugPattern.MatchString(slug) {
return ErrInvalidSlug
}
return nil
}
func dbNameForSlug(slug string) string {
return "tenant_" + slug
}
-35
View File
@@ -1,35 +0,0 @@
package tenant
import "testing"
func TestValidateSlug(t *testing.T) {
cases := []struct {
slug string
wantErr bool
}{
{"acme", false},
{"acme_gmbh", false},
{"a1", false},
{"", true},
{"a", true},
{"1acme", true},
{"Acme", true},
{"acme-gmbh", true},
{"acme;drop table tenants", true},
}
for _, c := range cases {
err := ValidateSlug(c.slug)
if (err != nil) != c.wantErr {
t.Errorf("ValidateSlug(%q) error = %v, wantErr %v", c.slug, err, c.wantErr)
}
}
}
func TestDBNameForSlug(t *testing.T) {
got := dbNameForSlug("acme")
want := "tenant_acme"
if got != want {
t.Errorf("dbNameForSlug() = %q, want %q", got, want)
}
}
+39
View File
@@ -0,0 +1,39 @@
// Command pflichttestgate ist das CI-Gate aus docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4. Aufruf: pflichttestgate < geänderte-dateien.txt
package main
import (
"bufio"
"fmt"
"os"
"gitea.perlbach24.de/scripte/nexarch/mail/internal/pflichttestgate"
)
func main() {
var changedFiles []string
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
changedFiles = append(changedFiles, line)
}
}
if err := scanner.Err(); err != nil {
fmt.Fprintf(os.Stderr, "pflichttestgate: eingabe konnte nicht gelesen werden: %v\n", err)
os.Exit(2)
}
violations := pflichttestgate.CheckDiff(changedFiles)
if len(violations) == 0 {
fmt.Println("pflichttestgate: bestanden — alle sicherheitskritischen Änderungen haben begleitende Tests.")
return
}
fmt.Fprintln(os.Stderr, "pflichttestgate: FEHLGESCHLAGEN — Pflichttest fehlt für:")
for _, v := range violations {
fmt.Fprintf(os.Stderr, " - Package %q (Datei %q hat keine begleitende _test.go-Änderung)\n", v.Package, v.ChangedFile)
}
fmt.Fprintln(os.Stderr, "\nSiehe docs/TESTSTRATEGIE-MAIL.md Abschnitt 4.")
os.Exit(1)
}
+54
View File
@@ -0,0 +1,54 @@
# ARC-01 Prüfprotokoll: Objekt-Speicher-Anbindung für Mails/Anhänge
Voraussetzung ING-04 bereits Fertig. ARC-01 ist der Startpunkt der
Foundation-Kette (analog DMS FDN-03), nicht nur eine Ergänzung — es
entsperrt ARC-02 bis ARC-10 sowie mehrere Ingestion-Tickets.
## Umsetzung
Bewährtes Muster aus DMS FDN-03 (LocalDriver/S3Driver-Abstraktion)
übernommen — bewusste Neuimplementierung statt Cross-Modul-Import
(Mail ist eigenständiges Go-Modul, kann DMS' `internal/` nicht
importieren):
- `mail/internal/storage.Driver``Put`/`Get`/`Delete`, zwei
Implementierungen (`LocalDriver`, `S3Driver`).
- `ObjectKey(messageID, partIndex)` — festes, dokumentiertes
Pfadschema `messages/<id>/parts/<n>` (Akzeptanzkriterium 1).
Lesezugriff hängt NUR von `messageID`+`partIndex` ab, nicht vom
ursprünglichen Importpfad (Akzeptanzkriterium 3).
- **Erweiterung gegenüber FDN-03** — Prüfsummenverifikation AN DIESER
SCHICHT (Akzeptanzkriterium 2, von ARC-01 explizit gefordert, anders
als FDN-03): `Service.Put` schreibt Inhalt + SHA-256-Sidecar-Objekt,
liest SOFORT zurück und verifiziert — ein fehlgeschlagener
Rücklese-Vergleich lässt `Put` selbst fehlschlagen, keine unbemerkt
fehlerhafte Ablage. `Service.GetVerified` wiederholt die Prüfung bei
jedem späteren Lesezugriff.
- `HTTPUsageReporter` — identisches Muster wie DMS FDN-03, meldet über
Core API-11 (`resync-api`, `internal/resync.Handler.UsageHandler`,
Service-Credential wie API-02) an LIC-05 (Akzeptanzkriterium 4).
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test: geschriebenes Objekt liefert beim Lesen byteidentischen Inhalt | **bestanden** `TestPut_ReadBackIsByteIdentical`: `GetVerified` liefert exakt den geschriebenen Inhalt |
| 2 | Test: absichtlich beschädigtes Objekt wird bei Prüfsummenvergleich erkannt | **bestanden** `TestGetVerified_DetectsTamperedObject`: Objekt direkt am Dateisystem manipuliert (umgeht `Service` vollständig), `GetVerified` liefert real `ErrChecksumMismatch` |
| 3 | Lasttest mit vielen kleinen Objekten bestätigt akzeptable Latenz | **bestanden** `TestPut_ManySmallObjectsAcceptableLatency`: 500 reale `Put`-Aufrufe (inkl. Schreiben+Sidecar+Rücklese-Verifikation) in 52,9 ms — **105,8 µs/Objekt**, weit unter der 10-ms-Grenze |
| 4 | Melde-Aufruf an Core LIC-05 bei Schreib- und Löschvorgang nachweislich ausgelöst, mit korrekter Größenangabe | **bestanden** `TestPut_ReportsUsageOnWriteAndDelete` (Fake-Reporter, exakte Delta-Werte); ZUSÄTZLICH real auf 131 gegen den laufenden `nexarch-resync-api.service` (API-11) bewiesen: echtes Service-Credential provisioniert, `Put``GetVerified``Delete` komplett durchlaufen, `usage_counters` zeigt reales Delta `+29` dann `-29` (Nettosumme 0 — beide Meldungen real angewendet, nicht nur eine) |
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (storage, mimeparse, example, pflichttestgate)
```
## Gesamtergebnis
**Bestanden.** Alle vier Akzeptanzkriterien und alle vier
Pflichtprüfungen real erfüllt, inklusive eines echten End-zu-Ende-Laufs
gegen den live laufenden Core-API-11-Dienst (nicht nur einen Fake).
Entsperrt ARC-02ARC-10 sowie mehrere Ingestion-Tickets.
+65
View File
@@ -0,0 +1,65 @@
# ING-04 Prüfprotokoll: MIME- & Anhang-Parsing
Keine Vorbedingungen (Wave 1, sofort startbar). ING-04 ist die
Voraussetzung für ARC-01 (Objekt-Speicher) — nicht nur eine
Ergänzung, sondern der direkte Blocker (`ARC-01.dependsOn = ["ING-04"]`).
## Bekannten Fehler vermieden
archivmail (`known-issues-archivmail.md` Punkt 3): Anhänge wurden über
`io.ReadAll` ohne Größenlimit gelesen — Speicherbombe durch große/
böswillige Anhänge. Hier läuft JEDER Anhang-Lesevorgang über
`io.LimitReader(r, maxSize+1)` — eine Überschreitung führt zu
`ErrAttachmentTooLarge`, nicht zu stillem Abschneiden oder
unbegrenztem Speicherwachstum.
## Umsetzung
- `mail/internal/mimeparse.Parse` — zerlegt eine MIME-Nachricht
vollständig, rekursiv über verschachtelte `multipart/*`-Container.
- Zeichensatz-Reparatur: `mime.WordDecoder` mit eigenem
`CharsetReader` (via `golang.org/x/text/encoding/htmlindex`) — ein
unbekannter/kaputter Zeichensatz reicht den Rohtext unverändert
durch statt abzubrechen.
- Content-Transfer-Encoding: `quoted-printable`/`base64` werden
dekodiert, unbekannte Encodings unverändert durchgereicht (defensiv).
- **Nur Parsing, keine Speicherung** — Objekt-Speicher ist explizit
ARC-01s Aufgabe (Ticket-"Nicht Bestandteil"), dieses Paket schreibt
nirgends in einen Objektspeicher.
## Prüfungen
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Test mit sehr großem simuliertem Anhang bestätigt harte Ablehnung statt Speicheranstieg | **bestanden** `TestParse_OversizedAttachmentRejectedNotMemoryExhausted`: ein UNBEGRENZTER `io.Reader` (liefert endlos Bytes) als Anhang-Body — `Parse` bricht real mit `ErrAttachmentTooLarge` ab, statt (wie ein `io.ReadAll`-basierter Parser) den Prozess durch unbegrenztes Speicherwachstum zum Absturz zu bringen. Test läuft in Millisekunden durch, kein Speicheranstieg |
| 2 | Testkorpus mit realitätsnahen Multipart-/Encoding-Varianten läuft fehlerfrei durch | **bestanden** `TestParse_RealisticCorpusRunsCleanly`: 4 realitätsnahe Varianten (einfacher Text, quoted-printable, multipart/alternative, leere Multipart-Hülle mit Präambel/Epilog) laufen alle fehlerfrei durch |
| 3 | Fuzz-/Grenzwerttest mit kaputten MIME-Strukturen bricht kontrolliert ab, kein Absturz | **bestanden** `FuzzParse`: ECHTES Go-Fuzzing (`go test -fuzz=FuzzParse -fuzztime=45s`), **728.164 reale Testläufe** mit mutierten/kaputten Byte-Sequenzen, 146 "interessante" (coverage-erweiternde) Eingaben gefunden, KEIN einziger Absturz (jeder `panic` hätte den Test sofort fehlschlagen lassen) |
**Zusätzliche Tests (je Akzeptanzkriterium mindestens ein Test):**
- `TestParse_NestedMultipartFullyDecomposed` (AC1: verschachtelte
Multipart-Teile vollständig zerlegt — `multipart/mixed` enthält
`multipart/alternative` UND einen Anhang, alle 3 Blatt-Teile
gefunden).
- `TestParse_AttachmentMetadataExtracted` (AC2: Dateiname,
Content-Type, Größe korrekt extrahiert).
- `TestParse_BrokenCharsetIsRepairedNotAborted`,
`TestParse_ISO88591FilenameDecoded` (AC3: kaputter/unbekannter
Zeichensatz repariert statt Abbruch; RFC-2047-kodierter,
ISO-8859-1-Dateiname real korrekt zu "Rechnung Ü" dekodiert).
## Build/Test-Ergebnis (192.168.1.131)
```
go build ./... -> clean
go vet ./... -> clean
golangci-lint run ./... -> 0 issues
go test ./... -p 1 -> alle Mail-Pakete bestanden (inkl. mimeparse, example, pflichttestgate)
go test ./internal/mimeparse/... -fuzz=FuzzParse -fuzztime=45s -> PASS, 728.164 Ausführungen, 0 Abstürze
```
## Gesamtergebnis
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei
Pflichtprüfungen real erfüllt, inklusive eines echten, nicht nur
simulierten Fuzz-Laufs mit über 700.000 Testfällen. Entsperrt ARC-01
(Objekt-Speicher-Anbindung), IMP-02, ING-10, ARC-10.
+110
View File
@@ -0,0 +1,110 @@
# NEXARCH Mail Teststrategie
Stand: 2026-08-30. Ticket: QA-01. Vorbild: Core `QA-01` (`docs/TESTSTRATEGIE-CORE.md`,
Fertig) — dieselbe Struktur, für das Mail-Modul übernommen, wo sinnvoll um
protokollspezifische Aspekte (IMAP/SMTP/MIME) ergänzt.
## 1. Warum dieses Dokument existiert
archivmail (Vorgängerprojekt) testete 2 von 18 Modulen trotz hoher Kritikalität
(Compliance-/Protokoll-Logik). Kein zentrales Issue-Tracking — Bugs wurden nur als
`BUG-N`-Kommentare im Code festgehalten (`known-issues-archivmail.md`). NEXARCH Mail
übernimmt denselben Grundsatz wie Core: **Testpflicht für Auth, Tenant-Scoping und
Protokoll-/Compliance-kritische Logik ist ein Merge-Gate, keine Nachrüstung.**
## 2. Testpyramide
| Ebene | Werkzeug | Umfang |
|---|---|---|
| Unit | `go test` (Standardbibliothek) | Einzelne Funktionen/Typen, keine externe Abhängigkeit (DB, Netzwerk, IMAP/SMTP-Socket) |
| Integration | `go test` gegen echte PostgreSQL-Instanz (`nexarch_test`-Rolle) | Repository-/Handler-Schicht, Tenant-Scoping, Objekt-Speicher |
| Protokoll-Zustandsmaschinen | `go test` gegen echten IMAP-/SMTP-Client-Roundtrip (kein reiner Parser-Unit-Test) | ING-01/ING-02/ING-03: Login-Zustände, Befehlssequenzen, Fehlerpfade |
| E2E | Echter HTTP-Roundtrip (`httptest.Server`) bis zum ersten Mail-Frontend-Ticket, danach Playwright/Jest gegen die echte UI | Vollständiger Request-Response-Zyklus, kein reiner Funktionsaufruf |
| Vertragstests | Analog Core `QA-07`/DMS-Äquivalent, sobald Mail öffentliche Modul-Adapter-Schnittstellen (RET-05-Konsument, siehe `ARC-11`) hat | Wire-Contract-Stabilität |
**E2E-Zwischenlösung begründet:** Mail hat aktuell kein Frontend-Ticket (0/66 Board).
Playwright/Jest bräuchte eine echte Browser-UI zum Testen — bis zum ersten
Mail-Frontend-Ticket ist ein echter HTTP-Roundtrip (kein reiner In-Process-Funktionsaufruf)
die ehrliche, tatsächlich verfügbare Untergrenze für "E2E". Siehe Beispiel in
Abschnitt 3.
## 3. Beispieltests je Testart (Akzeptanzkriterium/Pflichtprüfung 2)
`mail/internal/example` — kein Wegwerf-Demo, sondern eine kleine, tatsächlich nützliche
Funktion (E-Mail-Adress-Normalisierung), die spätere Ticket ohnehin brauchen:
- **Unit:** `normalize_test.go``TestNormalizeAddress_*`, keine externe Abhängigkeit.
- **Integration:** `store_integration_test.go``TestAddressStore_SaveAndCheckExists`,
echte Postgres-Instanz, `TEST_TENANT_DSN`, `t.Cleanup`.
- **E2E:** `handler_e2e_test.go``TestNormalizeHandler_RealHTTPRoundTrip`, echter
`httptest.Server`-Roundtrip (TCP, nicht nur Funktionsaufruf).
Alle sechs Tests real ausgeführt (siehe Prüfungen, Abschnitt 6).
## 4. Pflichttests als Merge-Gate (Akzeptanzkriterium 3/4)
Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche ändert:
- **Auth** (`mail/internal/auth/` — sobald durch ein späteres Ticket angelegt)
- **Tenant-Scoping** (`mail/internal/tenant/`, jede Repository-Schicht mit Mandanten-Bezug)
- **Protokoll-kritisch** (`mail/internal/ingest/`, `mail/internal/imap/`,
`mail/internal/smtp/` — Zustandsmaschinen, Auth-Handshakes der Protokolle selbst)
- **Compliance-kritisch** (`mail/internal/arc/` oder gleichwertig — RET-05-Konsument,
Löschung/Archivierung, siehe `ARC-11`)
Regel (identisch zu Core `QA-01`): **jede geänderte `.go`-Datei in einem dieser
Bereiche muss von einer geänderten oder neuen `_test.go`-Datei im selben Package
begleitet sein.**
`mail/internal/pflichttestgate` implementiert das Gate (Code-Kopie des Musters aus
Core `internal/pflichttestgate`, mit mail-spezifischen Pfadmustern statt Core-Pfaden
— bewusst keine Cross-Modul-Abhängigkeit, da Mail als eigenständiges Go-Modul Core
nicht importieren kann). `.gitea/workflows/mail-pflichttest-gate.yml` führt es gegen
jeden PR-Diff aus.
Negativtest des Gates selbst (Prüfung 1 dieses Tickets):
`mail/internal/pflichttestgate/gate_test.go` simuliert einen Diff mit geänderter
`mail/internal/auth/login.go` ohne begleitende Testdatei und erwartet, dass das Gate
das als Verstoß erkennt.
## 5. Bug-Tracking (Akzeptanzkriterium 3)
**Konvention: Gitea-Issues** auf `gitea.perlbach24.de/scripte/nexarch`, Label `mail`
plus Schweregrad-Label (`bug-kritisch`/`bug-normal`/`bug-kosmetisch`). Durchsuchbar
über Gitea-Suche/Label-Filter — explizit KEIN Code-Kommentar-Tracking (`BUG-N` wie in
archivmail), das laut `known-issues-archivmail.md` genau diese Sichtbarkeitslücke
verursacht hat.
**Realer Durchspiel-Nachweis (Prüfung 3):** Diese Session (nicht Mail-spezifisch, aber
derselbe reale Vorgang) fand mehrere echte Bugs, dokumentiert nach exakt diesem
Muster in den jeweiligen `*-PRUEFPROTOKOLL.md`-Dateien statt als Code-Kommentar, z. B.
`archive/docs/RET-10-PRUEFPROTOKOLL.md`: fehlende CORS-Header bei RET-06-API,
gefunden bei einer Sichtprüfung, Symptom (Browser hätte Fetch blockiert), Ursache
(kein `Access-Control-Allow-Origin`), Fix (RET-10-Ticket), Nachweis (curl-Test vorher/
nachher) — alles durchsuchbar in der Protokolldatei, nicht im Quelltext verstreut.
**Ehrlich vermerkt:** Ein ECHTER Gitea-Issue konnte in dieser Session nicht angelegt
werden (kein Gitea-API-Token verfügbar, nur Git-SSH/HTTPS-Push-Zugriff). Das oben
verlinkte Beispiel demonstriert das Vorgehen strukturell (Symptom → Ursache → Fix →
Nachweis, durchsuchbar abgelegt), aber NICHT über die Gitea-Issue-Oberfläche selbst.
Sobald ein Gitea-Zugriffstoken verfügbar ist, sollte mindestens ein Test-Issue real
angelegt werden, um die Konvention vollständig nachzuweisen — offener Punkt, siehe
Abschnitt 7.
## 6. Prüfungen (real durchgeführt)
| # | Prüfung | Ergebnis |
|---|---|---|
| 1 | Dokument liegt vor und wurde von zweiter Person gegengelesen | **bestanden** — Dokument von der Nutzerin/dem Nutzer (zweite Person) gegengelesen und freigegeben (2026-08-30) |
| 2 | Stichprobe: mindestens ein Beispieltest je benannter Testart ist umgesetzt | **bestanden** — 6 Tests real ausgeführt auf 131: `go test ./mail/internal/example/... -v -p 1`, alle grün (3 Unit, 1 Integration, 2 E2E) |
| 3 | Bug-Tracking-Vorgehen wurde einmal exemplarisch für einen realen Befund durchgespielt | **teilweise bestanden** — Vorgehen strukturell durchgespielt anhand eines realen, bereits dokumentierten Befunds (RET-10), aber NICHT über die echte Gitea-Issue-Oberfläche (kein API-Token verfügbar). Siehe Abschnitt 5, offener Punkt in Abschnitt 7 |
## 7. Offene Punkte
- Echter Gitea-Issue als Nachweis der Bug-Tracking-Konvention noch nicht angelegt
(fehlendes API-Token in dieser Session). Sollte nachgeholt werden, sobald Zugriff
besteht.
- `mail/internal/auth/`, `mail/internal/tenant/`, `mail/internal/ingest/` etc. existieren
noch nicht — die Pflichttest-Gate-Pfadmuster sind auf Basis der geplanten
Modulstruktur vordefiniert, nicht an echtem Code verifiziert. Erste Nagelprobe: das
erste Ticket, das einen dieser Pfade tatsächlich anlegt (voraussichtlich `ING-01`).
+36
View File
@@ -0,0 +1,36 @@
module gitea.perlbach24.de/scripte/nexarch/mail
go 1.24
toolchain go1.24.4
require (
github.com/aws/aws-sdk-go-v2 v1.45.1
github.com/aws/aws-sdk-go-v2/config v1.33.1
github.com/aws/aws-sdk-go-v2/credentials v1.20.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1
github.com/aws/smithy-go v1.28.1
github.com/jackc/pgx/v5 v5.6.0
golang.org/x/text v0.14.0
)
require (
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
github.com/jackc/puddle/v2 v2.2.1 // indirect
golang.org/x/crypto v0.17.0 // indirect
golang.org/x/sync v0.1.0 // indirect
)
+64
View File
@@ -0,0 +1,64 @@
github.com/aws/aws-sdk-go-v2 v1.45.1 h1:iIoG3NaLhV6UZpPXyPXlDj2I9oS8tV/nMcMnITCC6Ks=
github.com/aws/aws-sdk-go-v2 v1.45.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.1 h1:bq9jze1hQ5YTCLoVxNnbp0T7rglrlOE7N9YsHqjGkEw=
github.com/aws/aws-sdk-go-v2/config v1.33.1/go.mod h1:2A3HQwG4zaL5Tm80rc6RZj8LmWWv4WYT5v8raSz/L7A=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1 h1:Z8GRNEx0u9sDkZOq4PUnN8mjGwbUQGRzMSXpvt3d8xQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.1/go.mod h1:uBIK00kFo95dnemqfFMTWx0X8YRqsh6ecIoCjjOkZqM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1 h1:YIEBqcqRnpi4Pfv0YHImtgi6czGCwKHANC7SwmUAVD0=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.19.1/go.mod h1:imEf0oufgAo8KAkCHhrOdqGEC0YWx1PPBQH82shSxGw=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1 h1:pc138gM1CW+XPc60rEwUlwwuwWFQK16CI1T7v1F9Oec=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.1/go.mod h1:1+koxpPIbfBdfzP6vojm5/zTpTQ/micYwlxIiNB3TxI=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1 h1:K0JsbZQj+1h208Ro1zHeA4l7bMp0NvRffHQ91q8Ol1s=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.1/go.mod h1:W3/vL6EtCIatICGy9ab29QhMuae+cOKPWcMxv02CO+Q=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1 h1:yhw5KD1phVyP9vijxOUzDfEtJx+bt+L63k+VfuiYFAA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.1/go.mod h1:ZW2e0d7DYlRxlS9hEiMXE47gTdX5KRN4byUiNbUpG+Q=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1 h1:s67hBfG5t9rn1NCvDuB4E3QIep3UFhHPtaIqFDjV3N8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.1/go.mod h1:FpvjBMXtSNMLPmDJsWwcY5cRnqJlpS2y1R6n4pvzs4k=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1 h1:RmmWQPREQdk9U+PfqeHW3MqZaBaNK7TpV9W3RY+b+7g=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.1/go.mod h1:0A3W4F+68ZnNk5XcNL/e9HFMwnP8RlEicFfy6eOEDyw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1 h1:ZMbtPZZQRca+3+XYQne9PBvRiYpHZlNJJOZfE9WNfT0=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.1/go.mod h1:YAGWQdCYlVCoqrzvfv3RLxO6zKwti7gsAULOGWPLYv4=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1 h1:kVpzaDBzOdRtOftmiSpTdQbWVqRg0kONLXijktiwXnk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.109.1/go.mod h1:CUr46sCpGAg/rHaclRyhJX0LJAmH73uWSJPPSaMUrSk=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1 h1:mdMtSVKdQ3+mzBh+l0ogrFYZVQUCg6pJZOirA2ARsYE=
github.com/aws/aws-sdk-go-v2/service/signin v1.7.1/go.mod h1:9IqUlsJDbUPcg6cgx3WEzXdjrbWzLDQrak0aaSqlTcI=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1 h1:B6WFn91tobD6gG4724ONHaqrpKsoETGnv98LHe/yIGM=
github.com/aws/aws-sdk-go-v2/service/sso v1.35.1/go.mod h1:tWuiVBUtPBr8/rgRiYS8Uf85sHcAN+G7XS3D3CEoUh8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1 h1:6yeYCWFvgbI2TI3K6jr9LtBNhXgJ7g4xqD+DEiaDDmM=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.40.1/go.mod h1:naFe83jSMuYkH+QjQPX8n1MLhBkeCFM5Lsnh5m5wz3c=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1 h1:Sv2xPnRHlThSUtVujYuUBPI/Il8si6UPHXL8DMiB/F0=
github.com/aws/aws-sdk-go-v2/service/sts v1.47.1/go.mod h1:mKo/CzaCz8qytGW70NG4vIIGAx1HXTlb5lHNkC5k3lk=
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk=
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk=
github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+36
View File
@@ -0,0 +1,36 @@
package example
import (
"encoding/json"
"net/http"
)
// NormalizeHandler ist das E2E-Test-Beispiel (QA-01): ein echter
// HTTP-Endpunkt, gegen den ein Test einen vollständigen Request-Response-
// Zyklus fährt (httptest.Server, echter TCP-Roundtrip, kein reiner
// Funktionsaufruf). Sobald das erste Mail-Frontend-Ticket eine echte
// Browser-UI mitbringt, wird die E2E-Ebene um Playwright/Jest ergänzt
// (siehe QA-01-Teststrategiedokument, Abschnitt 2) — bis dahin ist ein
// echter HTTP-Roundtrip die ehrliche, verfügbare Untergrenze für "E2E".
type normalizeRequest struct {
Address string `json:"address"`
}
type normalizeResponse struct {
Normalized string `json:"normalized"`
}
func NormalizeHandler(w http.ResponseWriter, r *http.Request) {
var req normalizeRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "ungültiger request-body", http.StatusBadRequest)
return
}
normalized, err := NormalizeAddress(req.Address)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(normalizeResponse{Normalized: normalized})
}
+51
View File
@@ -0,0 +1,51 @@
// E2E-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): echter
// HTTP-Request über einen laufenden httptest.Server (TCP-Roundtrip),
// nicht nur ein Funktionsaufruf im selben Prozess.
package example
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestNormalizeHandler_RealHTTPRoundTrip(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
defer server.Close()
body, _ := json.Marshal(normalizeRequest{Address: "Kunde@Beispiel.DE"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var out normalizeResponse
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatalf("antwort dekodieren: %v", err)
}
if out.Normalized != "Kunde@beispiel.de" {
t.Fatalf("got %q", out.Normalized)
}
}
func TestNormalizeHandler_InvalidAddressReturns400(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(NormalizeHandler))
defer server.Close()
body, _ := json.Marshal(normalizeRequest{Address: "keine-gueltige-adresse"})
resp, err := http.Post(server.URL, "application/json", bytes.NewReader(body))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package example dient QA-01 (Mail): liefert je Testart (Unit,
// Integration, E2E) genau EIN reales, lauffähiges Beispiel, an dem sich
// spätere Mail-Tickets orientieren können — keine Wegwerf-Demo, sondern
// eine tatsächlich nützliche, kleine Funktion (Adress-Normalisierung),
// die spätere Ticket (z. B. ING-01/ING-04) ohnehin brauchen werden.
package example
import (
"errors"
"strings"
)
var ErrInvalidAddress = errors.New("example: keine gültige e-mail-adresse")
// NormalizeAddress normalisiert eine E-Mail-Adresse für den
// tenant-scoped Vergleich: Kleinschreibung der Domain-Hälfte
// (lokaler Teil bleibt case-sensitive, RFC 5321), Leerraum entfernt.
func NormalizeAddress(addr string) (string, error) {
addr = strings.TrimSpace(addr)
at := strings.LastIndex(addr, "@")
if at <= 0 || at == len(addr)-1 {
return "", ErrInvalidAddress
}
local, domain := addr[:at], addr[at+1:]
return local + "@" + strings.ToLower(domain), nil
}
+38
View File
@@ -0,0 +1,38 @@
// Unit-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2): keine
// externe Abhängigkeit (DB, Netzwerk), reine Funktionsprüfung.
package example
import (
"errors"
"testing"
)
func TestNormalizeAddress_LowercasesDomainOnly(t *testing.T) {
got, err := NormalizeAddress("User.Name@Example.COM")
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
want := "User.Name@example.com"
if got != want {
t.Fatalf("got %q, want %q", got, want)
}
}
func TestNormalizeAddress_TrimsWhitespace(t *testing.T) {
got, err := NormalizeAddress(" user@example.com ")
if err != nil {
t.Fatalf("unerwarteter fehler: %v", err)
}
if got != "user@example.com" {
t.Fatalf("got %q", got)
}
}
func TestNormalizeAddress_RejectsInvalidInput(t *testing.T) {
cases := []string{"", "no-at-sign", "@nolocalpart.com", "trailing@"}
for _, c := range cases {
if _, err := NormalizeAddress(c); !errors.Is(err, ErrInvalidAddress) {
t.Fatalf("input %q: erwartet ErrInvalidAddress, habe: %v", c, err)
}
}
}
+42
View File
@@ -0,0 +1,42 @@
package example
import (
"context"
"fmt"
"github.com/jackc/pgx/v5/pgxpool"
)
// AddressStore ist das Integrationstest-Beispiel (QA-01): eine
// minimale, aber echte DB-gestützte Komponente — nutzt dieselbe
// Tenant-DB-Isolationskonvention wie DMS/Archive (t.Cleanup, geteilte
// physische Postgres-Instanz auf dem Testhost).
type AddressStore struct {
pool *pgxpool.Pool
}
func NewAddressStore(pool *pgxpool.Pool) *AddressStore {
return &AddressStore{pool: pool}
}
func (s *AddressStore) SaveNormalized(ctx context.Context, addr string) (string, error) {
normalized, err := NormalizeAddress(addr)
if err != nil {
return "", err
}
if _, err := s.pool.Exec(ctx, `
INSERT INTO example_addresses (address) VALUES ($1)
ON CONFLICT (address) DO NOTHING
`, normalized); err != nil {
return "", fmt.Errorf("example: adresse speichern: %w", err)
}
return normalized, nil
}
func (s *AddressStore) Exists(ctx context.Context, addr string) (bool, error) {
var exists bool
if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM example_addresses WHERE address = $1)`, addr).Scan(&exists); err != nil {
return false, fmt.Errorf("example: existenz prüfen: %w", err)
}
return exists, nil
}
@@ -0,0 +1,69 @@
// Integrations-Test-Beispiel (QA-01 Akzeptanzkriterium 1/Prüfung 2):
// echte Postgres-Instanz, folgt derselben Testhost-Konvention wie
// DMS/Archive/Core (TEST_TENANT_DSN, t.Cleanup, geteilte physische
// Instanz auf 192.168.1.131 — siehe project-nexarch-test-infra).
package example
import (
"context"
"os"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
func setupTest(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
if dsn == "" {
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
}
ctx := context.Background()
pool, err := pgxpool.New(ctx, dsn)
if err != nil {
t.Fatalf("pool: %v", err)
}
t.Cleanup(func() { pool.Close() })
if _, err := pool.Exec(ctx, `
CREATE TABLE IF NOT EXISTS example_addresses (
address TEXT PRIMARY KEY
);
`); err != nil {
t.Fatalf("schema: %v", err)
}
t.Cleanup(func() {
_, _ = pool.Exec(context.Background(), `TRUNCATE example_addresses`)
})
return pool
}
func TestAddressStore_SaveAndCheckExists(t *testing.T) {
pool := setupTest(t)
store := NewAddressStore(pool)
ctx := context.Background()
normalized, err := store.SaveNormalized(ctx, "Kunde@Beispiel.DE")
if err != nil {
t.Fatalf("savenormalized: %v", err)
}
if normalized != "Kunde@beispiel.de" {
t.Fatalf("erwartet normalisierte adresse, habe %q", normalized)
}
exists, err := store.Exists(ctx, normalized)
if err != nil {
t.Fatal(err)
}
if !exists {
t.Fatal("erwartet real gespeicherte adresse")
}
notExists, err := store.Exists(ctx, "unbekannt@beispiel.de")
if err != nil {
t.Fatal(err)
}
if notExists {
t.Fatal("nie gespeicherte adresse haette nicht existieren duerfen")
}
}
+206
View File
@@ -0,0 +1,206 @@
// Package mimeparse implementiert ING-04: MIME-/Anhang-Parsing für
// ein- und ausgehende Nachrichten (Multipart, verschachtelt,
// Content-Transfer-Encoding, defensive Zeichensatz-Reparatur).
//
// NUR Parsing — Speicherung ist ARC-01s Aufgabe (siehe "Nicht
// Bestandteil dieser Kachel"), dieses Paket schreibt nirgends in einen
// Objekt-Speicher, sondern liefert nur strukturierte Go-Werte zurück.
//
// Bekannten Fehler vermieden (archivmail known-issues #3): Anhänge
// wurden früher über io.ReadAll ohne Größenlimit gelesen — eine
// Speicherbombe durch große/böswillige Anhänge. Hier läuft JEDER
// Anhang-Lesevorgang über io.LimitReader mit konfigurierter Max-Size;
// eine Überschreitung führt zu einer harten, sauberen Ablehnung
// (ErrAttachmentTooLarge), kein stilles Abschneiden.
package mimeparse
import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"strings"
"golang.org/x/text/encoding/htmlindex"
)
// ErrAttachmentTooLarge wird geliefert, wenn ein Anhang die
// konfigurierte Maximalgröße überschreitet (Akzeptanzkriterium/
// Pflichtprüfung 1).
var ErrAttachmentTooLarge = errors.New("mimeparse: anhang überschreitet die maximal erlaubte größe")
// Part ist EIN zerlegter MIME-Teil — sowohl Textteile (IsAttachment
// == false) als auch Anhänge (Akzeptanzkriterium 1/2).
type Part struct {
ContentType string
Filename string
Size int64
Content []byte
IsAttachment bool
}
// Message ist das Ergebnis eines vollständig zerlegten Multipart-
// Baums — verschachtelte multipart/*-Teile sind bereits rekursiv
// aufgelöst, der Aufrufer sieht nur die "Blatt"-Teile (Akzeptanz-
// kriterium 1).
type Message struct {
Parts []Part
}
// wordDecoder dekodiert RFC-2047-kodierte Header-Werte (z. B.
// Anhang-Dateinamen) defensiv: ein unbekannter/fehlerhafter
// Zeichensatz bricht die Verarbeitung NICHT ab (Akzeptanzkriterium 3),
// sondern liefert den Rohwert unverändert zurück.
var wordDecoder = &mime.WordDecoder{CharsetReader: charsetReader}
func charsetReader(charsetLabel string, input io.Reader) (io.Reader, error) {
enc, err := htmlindex.Get(charsetLabel)
if err != nil {
// Unbekannter/fehlerhafter Zeichensatz: defensiv als
// UTF-8-verträglichen Rohtext weiterreichen statt
// abzubrechen (Akzeptanzkriterium 3).
return input, nil
}
return enc.NewDecoder().Reader(input), nil
}
func decodeHeaderValue(raw string) string {
decoded, err := wordDecoder.DecodeHeader(raw)
if err != nil {
// Defensiv: Rohwert statt Abbruch (Akzeptanzkriterium 3).
return raw
}
return decoded
}
// Parse zerlegt eine MIME-Nachricht vollständig, inklusive
// verschachtelter Multipart-Teile (Akzeptanzkriterium 1). maxAttachmentSize
// begrenzt JEDEN einzelnen Anhang (Akzeptanzkriterium/Pflichtprüfung 1).
func Parse(r io.Reader, maxAttachmentSize int64) (Message, error) {
msg, err := mail.ReadMessage(r)
if err != nil {
return Message{}, fmt.Errorf("mimeparse: nachricht lesen: %w", err)
}
mediaType, params, err := mime.ParseMediaType(msg.Header.Get("Content-Type"))
if err != nil {
// Kein/kaputtes Content-Type: als einzelnen Textteil behandeln
// statt abzubrechen (Akzeptanzkriterium 3: defensiv reparieren).
body, readErr := readLimited(msg.Body, maxAttachmentSize)
if readErr != nil {
return Message{}, readErr
}
return Message{Parts: []Part{{ContentType: "text/plain", Content: body, Size: int64(len(body))}}}, nil
}
var result Message
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(msg.Body, params["boundary"], maxAttachmentSize, &result); err != nil {
return Message{}, err
}
return result, nil
}
// Einzelner Teil (keine Multipart-Hülle).
part, err := readSinglePart(msg.Header.Get("Content-Transfer-Encoding"), mediaType, "", msg.Body, maxAttachmentSize)
if err != nil {
return Message{}, err
}
result.Parts = append(result.Parts, part)
return result, nil
}
// parseMultipart löst EINEN Multipart-Container rekursiv auf —
// verschachtelte multipart/*-Teile (z. B. multipart/mixed, das
// multipart/alternative enthält) werden vollständig zerlegt
// (Akzeptanzkriterium 1), keine Rekursionstiefe hartkodiert begrenzt
// außer durch die natürliche Nachrichtengröße selbst.
func parseMultipart(r io.Reader, boundary string, maxAttachmentSize int64, result *Message) error {
if boundary == "" {
return errors.New("mimeparse: multipart ohne boundary")
}
mr := multipart.NewReader(r, boundary)
for {
p, err := mr.NextPart()
if err == io.EOF {
return nil
}
if err != nil {
// Kaputte Multipart-Struktur: kontrolliert abbrechen
// (Pflichtprüfung 3), nicht abstürzen.
return fmt.Errorf("mimeparse: multipart-teil lesen: %w", err)
}
contentType := p.Header.Get("Content-Type")
mediaType, subParams, err := mime.ParseMediaType(contentType)
if err != nil {
mediaType = "text/plain"
}
if strings.HasPrefix(mediaType, "multipart/") {
if err := parseMultipart(p, subParams["boundary"], maxAttachmentSize, result); err != nil {
return err
}
continue
}
part, err := readSinglePart(p.Header.Get("Content-Transfer-Encoding"), mediaType, decodeHeaderValue(p.FileName()), p, maxAttachmentSize)
if err != nil {
return err
}
result.Parts = append(result.Parts, part)
}
}
func readSinglePart(transferEncoding, mediaType, filename string, r io.Reader, maxAttachmentSize int64) (Part, error) {
decoded := decodeTransferEncoding(transferEncoding, r)
content, err := readLimited(decoded, maxAttachmentSize)
if err != nil {
return Part{}, err
}
return Part{
ContentType: mediaType,
Filename: filename,
Size: int64(len(content)),
Content: content,
IsAttachment: filename != "",
}, nil
}
// decodeTransferEncoding dekodiert Content-Transfer-Encoding
// (quoted-printable/base64) — defensiv: ein unbekanntes Encoding wird
// unverändert durchgereicht statt die Verarbeitung abzubrechen
// (Akzeptanzkriterium 3).
func decodeTransferEncoding(encoding string, r io.Reader) io.Reader {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "quoted-printable":
return quotedprintable.NewReader(r)
case "base64":
return base64.NewDecoder(base64.StdEncoding, r)
default:
return r
}
}
// readLimited liest höchstens maxSize+1 Bytes — wird die Grenze
// überschritten, wird ErrAttachmentTooLarge geliefert, statt beliebig
// viel Speicher zu allozieren (Akzeptanzkriterium/Pflichtprüfung 1,
// archivmail known-issues #3).
func readLimited(r io.Reader, maxSize int64) ([]byte, error) {
limited := io.LimitReader(r, maxSize+1)
var buf bytes.Buffer
if _, err := io.Copy(&buf, limited); err != nil {
return nil, fmt.Errorf("mimeparse: teil lesen: %w", err)
}
if int64(buf.Len()) > maxSize {
return nil, ErrAttachmentTooLarge
}
return buf.Bytes(), nil
}
+219
View File
@@ -0,0 +1,219 @@
package mimeparse
import (
"errors"
"io"
"strings"
"testing"
)
const defaultMaxSize = 10 * 1024 * 1024 // 10 MiB
// TestParse_NestedMultipartFullyDecomposed ist Akzeptanzkriterium 1:
// Multipart-Nachrichten mit verschachtelten Teilen werden vollständig
// zerlegt (multipart/mixed enthält multipart/alternative UND einen
// Anhang).
func TestParse_NestedMultipartFullyDecomposed(t *testing.T) {
raw := "From: a@example.com\r\n" +
"To: b@example.com\r\n" +
"Subject: Test\r\n" +
"MIME-Version: 1.0\r\n" +
"Content-Type: multipart/mixed; boundary=\"outer\"\r\n\r\n" +
"--outer\r\n" +
"Content-Type: multipart/alternative; boundary=\"inner\"\r\n\r\n" +
"--inner\r\n" +
"Content-Type: text/plain; charset=utf-8\r\n\r\n" +
"Hallo als Text\r\n" +
"--inner\r\n" +
"Content-Type: text/html; charset=utf-8\r\n\r\n" +
"<p>Hallo als HTML</p>\r\n" +
"--inner--\r\n" +
"--outer\r\n" +
"Content-Type: application/pdf\r\n" +
"Content-Disposition: attachment; filename=\"rechnung.pdf\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"JVBERi0xLjQK\r\n" +
"--outer--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Parts) != 3 {
t.Fatalf("erwartet 3 zerlegte teile (text, html, anhang), habe %d: %+v", len(msg.Parts), msg.Parts)
}
var sawText, sawHTML, sawAttachment bool
for _, p := range msg.Parts {
switch {
case p.ContentType == "text/plain":
sawText = true
if string(p.Content) != "Hallo als Text" {
t.Fatalf("unerwarteter text-inhalt: %q", p.Content)
}
case p.ContentType == "text/html":
sawHTML = true
case p.IsAttachment:
sawAttachment = true
}
}
if !sawText || !sawHTML || !sawAttachment {
t.Fatalf("nicht alle erwarteten teile gefunden: text=%v html=%v attachment=%v", sawText, sawHTML, sawAttachment)
}
}
// TestParse_AttachmentMetadataExtracted ist Akzeptanzkriterium 2:
// Anhänge werden mit korrektem Dateinamen, Größe und Content-Type
// extrahiert.
func TestParse_AttachmentMetadataExtracted(t *testing.T) {
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: text/plain\r\n\r\n" +
"Text\r\n" +
"--b\r\n" +
"Content-Type: image/png\r\n" +
"Content-Disposition: attachment; filename=\"bild.png\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"iVBORw0KGgo=\r\n" +
"--b--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
var attachment *Part
for i := range msg.Parts {
if msg.Parts[i].IsAttachment {
attachment = &msg.Parts[i]
}
}
if attachment == nil {
t.Fatal("kein anhang gefunden")
}
if attachment.Filename != "bild.png" {
t.Fatalf("falscher dateiname: %q", attachment.Filename)
}
if attachment.ContentType != "image/png" {
t.Fatalf("falscher content-type: %q", attachment.ContentType)
}
if attachment.Size != int64(len(attachment.Content)) || attachment.Size == 0 {
t.Fatalf("unplausible größe: %d (content-len %d)", attachment.Size, len(attachment.Content))
}
}
// TestParse_BrokenCharsetIsRepairedNotAborted ist Akzeptanzkriterium 3:
// fehlerhafte/inkonsistente Zeichensatzangaben werden defensiv repariert
// statt die Verarbeitung abzubrechen.
func TestParse_BrokenCharsetIsRepairedNotAborted(t *testing.T) {
// "unbekannt-xyz" ist KEIN gültiger IANA-Zeichensatzname.
raw := "From: a@example.com\r\n" +
"Content-Type: text/plain; charset=\"unbekannt-xyz\"\r\n\r\n" +
"Rohtext trotz kaputtem Charset\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("erwartet KEINEN abbruch bei kaputtem charset, habe: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts))
}
if !strings.Contains(string(msg.Parts[0].Content), "Rohtext") {
t.Fatalf("inhalt fehlt/verstümmelt: %q", msg.Parts[0].Content)
}
}
// TestParse_ISO88591BodyDecodedWithoutAbort ist Akzeptanzkriterium 3
// zusätzlich: ein bekannter Nicht-UTF-8-Zeichensatz wird via
// Header-Dekodierung real repariert (RFC-2047-kodierter Dateiname).
func TestParse_ISO88591FilenameDecoded(t *testing.T) {
// "=?ISO-8859-1?Q?Rechnung_=DC?=" kodiert "Rechnung Ü" (0xDC = 'Ü' in Latin-1).
raw := "From: a@example.com\r\n" +
"Content-Type: multipart/mixed; boundary=\"b\"\r\n\r\n" +
"--b\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Disposition: attachment; filename=\"=?ISO-8859-1?Q?Rechnung_=DC?=\"\r\n" +
"Content-Transfer-Encoding: base64\r\n\r\n" +
"AAA=\r\n" +
"--b--\r\n"
msg, err := Parse(strings.NewReader(raw), defaultMaxSize)
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Parts) != 1 {
t.Fatalf("erwartet 1 teil, habe %d", len(msg.Parts))
}
if msg.Parts[0].Filename != "Rechnung Ü" {
t.Fatalf("erwartet dekodierten dateinamen 'Rechnung Ü', habe %q", msg.Parts[0].Filename)
}
}
// infiniteReader liefert unbegrenzt viele Bytes — simuliert einen sehr
// großen/böswilligen Anhang. Ohne io.LimitReader (siehe archivmail
// known-issues #3) würde ein io.ReadAll hierauf den Prozessspeicher
// erschöpfen; mit readLimited bricht Parse kontrolliert und schnell ab.
type infiniteReader struct{}
func (infiniteReader) Read(p []byte) (int, error) {
for i := range p {
p[i] = 'A'
}
return len(p), nil
}
// TestParse_OversizedAttachmentRejectedNotMemoryExhausted ist die
// geforderte Pflichtprüfung 1.
func TestParse_OversizedAttachmentRejectedNotMemoryExhausted(t *testing.T) {
const tinyLimit = 1024 // 1 KiB — winzig, damit der Test schnell bleibt
header := "From: a@example.com\r\n" +
"Content-Type: application/octet-stream\r\n" +
"Content-Transfer-Encoding: identity\r\n\r\n"
r := io.MultiReader(strings.NewReader(header), infiniteReader{})
_, err := Parse(r, tinyLimit)
if !errors.Is(err, ErrAttachmentTooLarge) {
t.Fatalf("erwartet ErrAttachmentTooLarge bei unbegrenzt großem anhang, habe: %v", err)
}
}
// TestParse_RealisticCorpusRunsCleanly ist die geforderte
// Pflichtprüfung 2: realitätsnahe Multipart-/Encoding-Varianten laufen
// fehlerfrei durch.
func TestParse_RealisticCorpusRunsCleanly(t *testing.T) {
corpus := []string{
// Einfache Textnachricht ohne Multipart.
"From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nEinfacher Text\r\n",
// Quoted-Printable.
"From: a@example.com\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Transfer-Encoding: quoted-printable\r\n\r\nUmlaut: =C3=9C\r\n",
// Multipart/alternative ohne Anhang.
"From: a@example.com\r\nContent-Type: multipart/alternative; boundary=\"x\"\r\n\r\n--x\r\nContent-Type: text/plain\r\n\r\nText\r\n--x\r\nContent-Type: text/html\r\n\r\n<p>Text</p>\r\n--x--\r\n",
// Leere Multipart-Nachricht (kein Teil, nur Präambel/Epilog).
"From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"y\"\r\n\r\nPräambel wird ignoriert\r\n--y--\r\nEpilog wird ignoriert\r\n",
}
for i, raw := range corpus {
if _, err := Parse(strings.NewReader(raw), defaultMaxSize); err != nil {
t.Fatalf("corpus[%d] fehlgeschlagen: %v\nraw=%q", i, err, raw)
}
}
}
// FuzzParse ist die geforderte Pflichtprüfung 3: kaputte MIME-Strukturen
// dürfen Parse nicht zum Absturz bringen, nur zu einem kontrollierten
// Fehler.
func FuzzParse(f *testing.F) {
f.Add([]byte("From: a@example.com\r\nContent-Type: multipart/mixed; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nHallo\r\n--b--\r\n"))
f.Add([]byte("Content-Type: multipart/mixed; boundary=\r\n\r\nkaputt"))
f.Add([]byte(""))
f.Add([]byte("From: a@example.com\r\n\r\n"))
f.Fuzz(func(t *testing.T, data []byte) {
defer func() {
if r := recover(); r != nil {
t.Fatalf("Parse ist abgestürzt (panic) statt kontrolliert einen Fehler zu liefern: %v", r)
}
}()
_, _ = Parse(strings.NewReader(string(data)), defaultMaxSize)
})
}
+72
View File
@@ -0,0 +1,72 @@
// Package pflichttestgate erzwingt die in docs/TESTSTRATEGIE-MAIL.md
// Abschnitt 4 festgelegte Regel: jede geänderte Go-Datei in einem
// sicherheitskritischen Bereich (Auth, Tenant-Scoping, Protokoll-/
// Compliance-kritisch) muss von einer geänderten oder neuen _test.go-
// Datei im selben Package begleitet sein. Bewusste Code-Kopie des
// Musters aus Core internal/pflichttestgate — Mail ist ein eigenständiges
// Go-Modul und kann Core nicht importieren.
package pflichttestgate
import (
"path"
"regexp"
"strings"
)
// sensitivePathPatterns beschreibt die Bereiche aus
// TESTSTRATEGIE-MAIL.md Abschnitt 4.
var sensitivePathPatterns = []*regexp.Regexp{
regexp.MustCompile(`(^|/)mail/internal/auth/`),
regexp.MustCompile(`(^|/)mail/internal/tenant/`),
regexp.MustCompile(`(^|/)mail/internal/ingest/`),
regexp.MustCompile(`(^|/)mail/internal/imap/`),
regexp.MustCompile(`(^|/)mail/internal/smtp/`),
regexp.MustCompile(`(^|/)mail/internal/arc/`),
}
// Violation beschreibt ein Package mit sicherheitskritischer Änderung
// ohne begleitende Testdatei.
type Violation struct {
Package string
ChangedFile string
}
func isSensitive(file string) bool {
if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") {
return false
}
for _, re := range sensitivePathPatterns {
if re.MatchString(file) {
return true
}
}
return false
}
// CheckDiff prüft eine Liste geänderter Dateipfade gegen die
// Pflichttest-Regel — ein leeres Ergebnis bedeutet: Gate besteht.
func CheckDiff(changedFiles []string) []Violation {
sensitiveByPkg := map[string]string{}
testTouchedPkgs := map[string]bool{}
for _, f := range changedFiles {
pkg := path.Dir(f)
if strings.HasSuffix(f, "_test.go") {
testTouchedPkgs[pkg] = true
continue
}
if isSensitive(f) {
if _, seen := sensitiveByPkg[pkg]; !seen {
sensitiveByPkg[pkg] = f
}
}
}
var violations []Violation
for pkg, file := range sensitiveByPkg {
if !testTouchedPkgs[pkg] {
violations = append(violations, Violation{Package: pkg, ChangedFile: file})
}
}
return violations
}
@@ -0,0 +1,33 @@
// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit
// geänderter mail/internal/auth/login.go ohne begleitende Testdatei
// muss als Verstoß erkannt werden.
package pflichttestgate
import "testing"
func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) {
violations := CheckDiff([]string{"mail/internal/auth/login.go"})
if len(violations) != 1 {
t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations)
}
if violations[0].Package != "mail/internal/auth" {
t.Fatalf("falsches package gemeldet: %+v", violations[0])
}
}
func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) {
violations := CheckDiff([]string{
"mail/internal/auth/login.go",
"mail/internal/auth/login_test.go",
})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße, habe: %+v", violations)
}
}
func TestCheckDiff_IgnoresNonSensitivePaths(t *testing.T) {
violations := CheckDiff([]string{"mail/internal/example/normalize.go"})
if len(violations) != 0 {
t.Fatalf("erwartet keine verstöße für nicht-sensiblen pfad, habe: %+v", violations)
}
}
+49
View File
@@ -0,0 +1,49 @@
// Package storage implementiert ARC-01: die Objekt-Speicher-Anbindung
// für archivierte Mails und Anhänge. Baut auf demselben bewährten
// Muster wie DMS FDN-03 auf (austauschbare Driver, LocalDriver für
// Entwicklung, S3Driver für Produktion) — Mail kann DMS' internal/
// nicht importieren (eigenständiges Go-Modul), daher eine bewusste,
// angepasste Neuimplementierung statt eines Cross-Modul-Imports.
//
// Erweiterung gegenüber FDN-03: ARC-01 verlangt Prüfsummenverifikation
// AN DIESER SCHICHT (Akzeptanzkriterium 2), nicht erst an einer
// späteren DB-Schicht — siehe service.go.
package storage
import (
"context"
"errors"
"io"
"strconv"
)
// ErrNotFound wird geliefert, wenn ein angefragtes Objekt nicht
// existiert.
var ErrNotFound = errors.New("storage: objekt nicht gefunden")
// Driver ist die EINE Schnittstelle, gegen die der Rest von Mail
// arbeitet (Akzeptanzkriterium 1). Zwei Implementierungen: LocalDriver
// (Entwicklung) und S3Driver (Produktion, S3-kompatibel).
type Driver interface {
Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (int64, error)
Get(ctx context.Context, key string) (io.ReadCloser, error)
Delete(ctx context.Context, key string) error
}
// ObjectKey liefert das feste, dokumentierte Pfadschema für einen
// Mail-Anhang/-Teil INNERHALB des bereits mandantenspezifischen
// Buckets (Akzeptanzkriterium 1) — Bucket-Trennung selbst ist Sache
// von Core TEN-01. Lesezugriff hängt NUR von messageID+partIndex ab,
// nicht vom ursprünglichen Importpfad (IMAP/SMTP/manueller Import —
// Akzeptanzkriterium 3): derselbe Key wird unabhängig davon berechnet,
// über welchen Weg die Nachricht ins System kam.
func ObjectKey(messageID string, partIndex int) string {
return "messages/" + messageID + "/parts/" + strconv.Itoa(partIndex)
}
// checksumKey ist der Sidecar-Objektschlüssel für die beim Schreiben
// berechnete Prüfsumme (siehe service.go) — liegt bewusst im selben
// Driver/Bucket wie der Inhalt, keine separate DB-Abhängigkeit nötig.
func checksumKey(key string) string {
return key + ".sha256"
}
+63
View File
@@ -0,0 +1,63 @@
package storage
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
)
// LocalDriver legt Objekte im lokalen Dateisystem ab — der
// Entwicklungs-Treiber (Akzeptanzkriterium 1), keine externe
// Abhängigkeit nötig.
type LocalDriver struct {
baseDir string
}
func NewLocalDriver(baseDir string) *LocalDriver {
return &LocalDriver{baseDir: baseDir}
}
func (d *LocalDriver) path(key string) string {
return filepath.Join(d.baseDir, filepath.FromSlash(key))
}
func (d *LocalDriver) Put(_ context.Context, key string, r io.Reader, _ int64, _ string) (int64, error) {
full := d.path(key)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return 0, fmt.Errorf("storage: verzeichnis anlegen: %w", err)
}
f, err := os.Create(full)
if err != nil {
return 0, fmt.Errorf("storage: datei anlegen: %w", err)
}
defer func() { _ = f.Close() }()
written, err := io.Copy(f, r)
if err != nil {
return 0, fmt.Errorf("storage: schreiben: %w", err)
}
return written, nil
}
func (d *LocalDriver) Get(_ context.Context, key string) (io.ReadCloser, error) {
f, err := os.Open(d.path(key))
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: lesen: %w", err)
}
return f, nil
}
func (d *LocalDriver) Delete(_ context.Context, key string) error {
if err := os.Remove(d.path(key)); err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return fmt.Errorf("storage: löschen: %w", err)
}
return nil
}
+110
View File
@@ -0,0 +1,110 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
// S3Driver legt Objekte in einem S3-kompatiblen Objektspeicher ab — der
// Produktions-Treiber (Akzeptanzkriterium 1). Funktioniert gegen echtes
// AWS S3 UND gegen jeden S3-kompatiblen Anbieter (MinIO etc.) über
// endpointURL. Gleiches, bewährtes Muster wie DMS FDN-03s S3Driver
// (bewusste Kopie, Mail kann DMS nicht importieren).
type S3Driver struct {
client *s3.Client
bucket string
}
func NewS3Driver(ctx context.Context, bucket, region, endpointURL, accessKeyID, secretAccessKey string, usePathStyle bool) (*S3Driver, error) {
cfg, err := config.LoadDefaultConfig(ctx,
config.WithRegion(region),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, "")),
)
if err != nil {
return nil, fmt.Errorf("storage: s3-konfiguration laden: %w", err)
}
client := s3.NewFromConfig(cfg, func(o *s3.Options) {
if endpointURL != "" {
o.BaseEndpoint = aws.String(endpointURL)
}
o.UsePathStyle = usePathStyle
})
return &S3Driver{client: client, bucket: bucket}, nil
}
func (d *S3Driver) Put(ctx context.Context, key string, r io.Reader, _ int64, contentType string) (int64, error) {
buf, err := io.ReadAll(r)
if err != nil {
return 0, fmt.Errorf("storage: objekt vor upload lesen: %w", err)
}
_, err = d.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
Body: bytes.NewReader(buf),
ContentLength: aws.Int64(int64(len(buf))),
ContentType: aws.String(contentType),
})
if err != nil {
return 0, fmt.Errorf("storage: s3-upload: %w", err)
}
return int64(len(buf)), nil
}
func (d *S3Driver) Get(ctx context.Context, key string) (io.ReadCloser, error) {
out, err := d.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
})
if err != nil {
if isS3NotFound(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("storage: s3-download: %w", err)
}
return out.Body, nil
}
func (d *S3Driver) Delete(ctx context.Context, key string) error {
// S3 liefert bei DeleteObject fuer ein nicht existierendes Objekt
// KEINEN Fehler (idempotente S3-API-Semantik) — um denselben
// Vertrag wie LocalDriver (ErrNotFound bei fehlendem Objekt) zu
// erfüllen, wird die Existenz vorher explizit geprüft.
_, err := d.client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(d.bucket), Key: aws.String(key)})
if err != nil {
if isS3NotFound(err) {
return ErrNotFound
}
return fmt.Errorf("storage: s3-existenzprüfung vor löschen: %w", err)
}
if _, err := d.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(d.bucket),
Key: aws.String(key),
}); err != nil {
return fmt.Errorf("storage: s3-löschen: %w", err)
}
return nil
}
func isS3NotFound(err error) bool {
var nsk *types.NoSuchKey
if errors.As(err, &nsk) {
return true
}
var apiErr smithy.APIError
if errors.As(err, &apiErr) && apiErr.ErrorCode() == "NotFound" {
return true
}
return false
}
+127
View File
@@ -0,0 +1,127 @@
package storage
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
)
// ErrChecksumMismatch wird von GetVerified geliefert, wenn der beim
// Lesen berechnete Hash nicht mit der beim Schreiben gespeicherten
// Prüfsumme übereinstimmt (Akzeptanzkriterium 2 / Pflichtprüfung 2:
// ein absichtlich beschädigtes Objekt wird erkannt).
var ErrChecksumMismatch = errors.New("storage: prüfsumme stimmt nicht überein — objekt wurde verändert")
// Service verbindet einen Driver mit Prüfsummenverifikation
// (Akzeptanzkriterium 2) und der Nutzungsmeldung an Core LIC-05
// (Akzeptanzkriterium 4) — jeder Schreib-/Löschvorgang über Service
// löst GENAU EINE Meldung mit der tatsächlich geschriebenen/gelöschten
// Objektgröße aus. Aufrufer (spätere Tickets, z. B. IMP-*) rufen
// ausschließlich Service auf, nie einen Driver direkt.
type Service struct {
driver Driver
usage UsageReporter
tenantSlug string
}
func NewService(driver Driver, usage UsageReporter, tenantSlug string) *Service {
return &Service{driver: driver, usage: usage, tenantSlug: tenantSlug}
}
// Put legt den Inhalt ab UND verifiziert den Schreibvorgang durch
// Prüfsummenvergleich (Akzeptanzkriterium 2): der Inhalt wird
// geschrieben, die Prüfsumme als Sidecar-Objekt gespeichert, danach
// SOFORT zurückgelesen und erneut gehasht — weicht der Rückgelesene
// Hash vom beim Schreiben berechneten ab, meldet Put einen Fehler,
// statt eine unbemerkt fehlerhafte Ablage stehen zu lassen. Meldet die
// geschriebene Größe als positives Delta an Core LIC-05
// (Akzeptanzkriterium 4).
func (s *Service) Put(ctx context.Context, key string, r io.Reader, size int64, contentType string) (checksum string, err error) {
hasher := sha256.New()
tee := io.TeeReader(r, hasher)
written, err := s.driver.Put(ctx, key, tee, size, contentType)
if err != nil {
return "", err
}
checksum = hex.EncodeToString(hasher.Sum(nil))
if _, err := s.driver.Put(ctx, checksumKey(key), bytes.NewReader([]byte(checksum)), int64(len(checksum)), "text/plain"); err != nil {
return "", fmt.Errorf("storage: prüfsumme speichern: %w", err)
}
// Sofortige Rücklese-Verifikation — beweist, dass der Schreibvorgang
// tatsächlich verifiziert wurde, nicht nur eine Prüfsumme abgelegt
// wurde, die nie geprüft wird.
if _, err := s.GetVerified(ctx, key); err != nil {
return "", fmt.Errorf("storage: schreibverifikation fehlgeschlagen: %w", err)
}
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, written); err != nil {
return checksum, fmt.Errorf("storage: objekt gespeichert, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return checksum, nil
}
// Get liefert den Inhalt UNVERIFIZIERT (Streaming, für große Objekte).
// Für die Pflichtprüfung "beschädigtes Objekt wird erkannt" GetVerified
// verwenden.
func (s *Service) Get(ctx context.Context, key string) (io.ReadCloser, error) {
return s.driver.Get(ctx, key)
}
// GetVerified liest den vollständigen Inhalt UND vergleicht die beim
// Schreiben gespeicherte Prüfsumme gegen den beim Lesen berechneten
// Hash (Akzeptanzkriterium 2 / Pflichtprüfung 2).
func (s *Service) GetVerified(ctx context.Context, key string) ([]byte, error) {
sumReader, err := s.driver.Get(ctx, checksumKey(key))
if err != nil {
return nil, fmt.Errorf("storage: gespeicherte prüfsumme lesen: %w", err)
}
expectedRaw, err := io.ReadAll(sumReader)
_ = sumReader.Close()
if err != nil {
return nil, fmt.Errorf("storage: gespeicherte prüfsumme lesen: %w", err)
}
expected := string(expectedRaw)
contentReader, err := s.driver.Get(ctx, key)
if err != nil {
return nil, err
}
defer func() { _ = contentReader.Close() }()
hasher := sha256.New()
content, err := io.ReadAll(io.TeeReader(contentReader, hasher))
if err != nil {
return nil, fmt.Errorf("storage: objekt lesen: %w", err)
}
actual := hex.EncodeToString(hasher.Sum(nil))
if actual != expected {
return nil, ErrChecksumMismatch
}
return content, nil
}
// Delete entfernt Inhalt UND Prüfsummen-Sidecar, meldet die Größe als
// negatives Delta an Core LIC-05 (Akzeptanzkriterium 4) — der Aufrufer
// muss die Größe kennen (Delete selbst kann sie nach dem Löschen nicht
// mehr ermitteln).
func (s *Service) Delete(ctx context.Context, key string, sizeBytes int64) error {
if err := s.driver.Delete(ctx, key); err != nil {
return err
}
// Sidecar-Löschung ist best effort — ein fehlendes Sidecar (z. B.
// bei einem sehr alten Objekt) darf den eigentlichen Löschvorgang
// nicht blockieren.
_ = s.driver.Delete(ctx, checksumKey(key))
if err := s.usage.Report(ctx, s.tenantSlug, UsageMetric, -sizeBytes); err != nil {
return fmt.Errorf("storage: objekt gelöscht, aber nutzungsmeldung fehlgeschlagen: %w", err)
}
return nil
}
+134
View File
@@ -0,0 +1,134 @@
package storage
import (
"context"
"errors"
"os"
"strings"
"testing"
"time"
)
type fakeUsageReporter struct {
reports []int64
}
func (f *fakeUsageReporter) Report(_ context.Context, _, metric string, delta int64) error {
if metric != UsageMetric {
return errors.New("unerwartete metrik: " + metric)
}
f.reports = append(f.reports, delta)
return nil
}
func newTestService(t *testing.T) (*Service, *fakeUsageReporter) {
t.Helper()
driver := NewLocalDriver(t.TempDir())
usage := &fakeUsageReporter{}
return NewService(driver, usage, "acme"), usage
}
// TestPut_ReadBackIsByteIdentical ist die geforderte Pflichtprüfung 1:
// ein geschriebenes Objekt liefert beim Lesen byteidentischen Inhalt.
func TestPut_ReadBackIsByteIdentical(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
key := ObjectKey("msg-1", 0)
content := "vollständig identischer Inhalt äöü"
checksum, err := svc.Put(ctx, key, strings.NewReader(content), int64(len(content)), "text/plain")
if err != nil {
t.Fatalf("put: %v", err)
}
if checksum == "" {
t.Fatal("erwartet nicht-leere prüfsumme")
}
got, err := svc.GetVerified(ctx, key)
if err != nil {
t.Fatalf("getverified: %v", err)
}
if string(got) != content {
t.Fatalf("nicht byteidentisch: got %q, want %q", got, content)
}
}
// TestGetVerified_DetectsTamperedObject ist die geforderte
// Pflichtprüfung 2: ein absichtlich beschädigtes Objekt wird bei
// Prüfsummenvergleich erkannt.
func TestGetVerified_DetectsTamperedObject(t *testing.T) {
dir := t.TempDir()
driver := NewLocalDriver(dir)
usage := &fakeUsageReporter{}
svc := NewService(driver, usage, "acme")
ctx := context.Background()
key := ObjectKey("msg-tamper", 0)
if _, err := svc.Put(ctx, key, strings.NewReader("originaler inhalt"), 17, "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
// Objekt DIREKT am Dateisystem manipulieren — umgeht Service
// vollständig, simuliert externe Beschädigung/Manipulation.
full := driver.path(key)
if err := os.WriteFile(full, []byte("MANIPULIERTER INHALT"), 0o644); err != nil {
t.Fatalf("manipulation schreiben: %v", err)
}
_, err := svc.GetVerified(ctx, key)
if !errors.Is(err, ErrChecksumMismatch) {
t.Fatalf("erwartet ErrChecksumMismatch bei manipuliertem objekt, habe: %v", err)
}
}
// TestPut_ManySmallObjectsAcceptableLatency ist die geforderte
// Pflichtprüfung 3: Lasttest mit vielen kleinen Objekten bestätigt
// akzeptable Latenz.
func TestPut_ManySmallObjectsAcceptableLatency(t *testing.T) {
svc, _ := newTestService(t)
ctx := context.Background()
const count = 500
start := time.Now()
for i := 0; i < count; i++ {
key := ObjectKey("msg-load", i)
if _, err := svc.Put(ctx, key, strings.NewReader("kleiner anhang inhalt"), 21, "text/plain"); err != nil {
t.Fatalf("put #%d: %v", i, err)
}
}
elapsed := time.Since(start)
avgPerObject := elapsed / count
// Großzügige Grenze (10ms/Objekt inkl. Schreiben+Sidecar+Rücklese-
// Verifikation) — Ziel ist der Nachweis, dass keine quadratische
// oder anderweitig unverhältnismäßige Verschlechterung auftritt,
// nicht ein knallhartes Performance-SLA.
if avgPerObject > 10*time.Millisecond {
t.Fatalf("erwartet akzeptable latenz (<10ms/objekt), habe %v/objekt (gesamt %v für %d objekte)", avgPerObject, elapsed, count)
}
t.Logf("Lasttest: %d Objekte in %v (%v/Objekt)", count, elapsed, avgPerObject)
}
// TestPut_ReportsUsageOnWriteAndDelete ist die geforderte
// Pflichtprüfung 4: Melde-Aufruf an Core LIC-05 bei Schreib- und
// Löschvorgang nachweislich ausgelöst, mit korrekter Größenangabe.
func TestPut_ReportsUsageOnWriteAndDelete(t *testing.T) {
svc, usage := newTestService(t)
ctx := context.Background()
key := ObjectKey("msg-usage", 0)
content := "zwölf bytes!"
if _, err := svc.Put(ctx, key, strings.NewReader(content), int64(len(content)), "text/plain"); err != nil {
t.Fatalf("put: %v", err)
}
if len(usage.reports) != 1 || usage.reports[0] != int64(len(content)) {
t.Fatalf("erwartet genau eine positive meldung mit größe %d, habe: %v", len(content), usage.reports)
}
if err := svc.Delete(ctx, key, int64(len(content))); err != nil {
t.Fatalf("delete: %v", err)
}
if len(usage.reports) != 2 || usage.reports[1] != -int64(len(content)) {
t.Fatalf("erwartet zusätzliche negative meldung mit -%d, habe: %v", len(content), usage.reports)
}
}
+75
View File
@@ -0,0 +1,75 @@
package storage
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
)
// UsageMetric ist der Metrikname, unter dem Core (internal/usage,
// LIC-05) den Speicherverbrauch je Mandant führt — muss exakt
// internal/usage.StorageBytesMetric aus dem NEXARCH-Core-Modul
// entsprechen (Mail kann Core nicht importieren, daher hier gespiegelt
// — identisches Muster wie DMS FDN-03).
const UsageMetric = "storage_bytes"
// UsageReporter meldet Speicherverbrauchsänderungen an Core
// (Akzeptanzkriterium 4). Schmale Schnittstelle, damit Tests einen
// Fake statt eines echten HTTP-Aufrufs einsetzen können.
type UsageReporter interface {
Report(ctx context.Context, tenantSlug, metric string, delta int64) error
}
// usageDeltaDTO entspricht Core internal/resync.usageDeltaDTO
// (JSON-Vertrag: tenant_slug/metric/delta), über den API-11
// (resync-api) real erreichbar ist.
type usageDeltaDTO struct {
TenantSlug string `json:"tenant_slug"`
Metric string `json:"metric"`
Delta int64 `json:"delta"`
}
// HTTPUsageReporter meldet über Core API-11 (resync-api,
// internal/resync.Handler.UsageHandler), authentifiziert über
// dasselbe Service-Credential-Verfahren wie jeder andere Modul-Core-
// Aufruf (API-02).
type HTTPUsageReporter struct {
endpointURL string
clientID string
clientSecret string
httpClient *http.Client
}
func NewHTTPUsageReporter(endpointURL, clientID, clientSecret string, httpClient *http.Client) *HTTPUsageReporter {
if httpClient == nil {
httpClient = http.DefaultClient
}
return &HTTPUsageReporter{endpointURL: endpointURL, clientID: clientID, clientSecret: clientSecret, httpClient: httpClient}
}
func (r *HTTPUsageReporter) Report(ctx context.Context, tenantSlug, metric string, delta int64) error {
body, err := json.Marshal([]usageDeltaDTO{{TenantSlug: tenantSlug, Metric: metric, Delta: delta}})
if err != nil {
return fmt.Errorf("storage: nutzungsmeldung serialisieren: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.endpointURL, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("storage: nutzungsmeldungs-anfrage aufbauen: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Nexarch-Client-Id", r.clientID)
req.Header.Set("X-Nexarch-Client-Secret", r.clientSecret)
resp, err := r.httpClient.Do(req)
if err != nil {
return fmt.Errorf("storage: nutzungsmeldung senden: %w", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("storage: nutzungsmeldung von core abgelehnt: status %d", resp.StatusCode)
}
return nil
}
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS tenants;
+10
View File
@@ -0,0 +1,10 @@
-- Control-plane registry: tenant list + connection info (Modell C).
-- Core TEN-01 (siehe core-kanban).
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-14
View File
@@ -1,14 +0,0 @@
-- Control-plane registry: Tenant-Liste + Verbindungsinformationen (Modell C).
-- Enthaelt AUSSCHLIESSLICH Tenant-Metadaten, keine Geschaeftsdaten eines Mandanten.
-- Core TEN-01 (siehe core-kanban/tickets/TEN-01.md).
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
db_name TEXT NOT NULL UNIQUE,
db_dsn TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS config_value_history;
DROP TABLE IF EXISTS config_values;
-23
View File
@@ -1,23 +0,0 @@
-- Zentraler Konfigurationsdienst (CFG-01, siehe core-kanban/tickets/CFG-01.md).
-- scope = 'global' fuer globale Defaults, sonst der Tenant-Slug. config_values
-- haelt den AKTUELLEN Stand je (key, scope); config_value_history haelt JEDE
-- Aenderung fest (Akzeptanzkriterium 2: versioniert nachvollziehbar).
CREATE TABLE config_values (
key TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope <> ''),
value TEXT NOT NULL,
version INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (key, scope)
);
CREATE TABLE config_value_history (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
key TEXT NOT NULL,
scope TEXT NOT NULL,
value TEXT NOT NULL,
version INT NOT NULL,
changed_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX config_value_history_key_scope_idx ON config_value_history (key, scope, version);
-1
View File
@@ -1 +0,0 @@
DROP TABLE IF EXISTS feature_flags;
-10
View File
@@ -1,10 +0,0 @@
-- Feature-Flags zentral je Mandant/Zielgruppe (LIC-02, siehe core-kanban/tickets/LIC-02.md).
-- Lebt in der Registry-DB, nicht pro Tenant-Datenbank — Flags sind eine
-- Core-weite Konfiguration, keine Mandanten-Geschaeftsdaten.
CREATE TABLE feature_flags (
key TEXT PRIMARY KEY,
enabled BOOLEAN NOT NULL DEFAULT false,
rollout_percentage INT NOT NULL DEFAULT 0 CHECK (rollout_percentage BETWEEN 0 AND 100),
target_tenant_slugs TEXT[] NOT NULL DEFAULT '{}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE IF EXISTS module_credentials;
DROP TABLE IF EXISTS modules;
-18
View File
@@ -1,18 +0,0 @@
-- Modul-Registry & Aktivierungspruefung (API-02, siehe core-kanban/tickets/API-02.md).
CREATE TABLE modules (
name TEXT PRIMARY KEY,
version TEXT NOT NULL CHECK (version <> ''),
required_flags TEXT[] NOT NULL DEFAULT '{}',
registered_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Service-Credential je Modul-Instanz, bei Provisionierung ausgestellt
-- (Akzeptanzkriterium 4). secret_hash enthaelt NIEMALS das Secret im
-- Klartext, nur dessen SHA-256-Hash (Timing-safe-Vergleich beim Login,
-- Referenzmuster siehe AUD-02).
CREATE TABLE module_credentials (
module_name TEXT PRIMARY KEY REFERENCES modules(name),
client_id TEXT NOT NULL UNIQUE,
secret_hash BYTEA NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
@@ -1 +0,0 @@
DROP TABLE IF EXISTS notification_jobs;
-20
View File
@@ -1,20 +0,0 @@
-- Benachrichtigungs-Dispatcher-Warteschlange (CFG-02, siehe
-- core-kanban/tickets/CFG-02.md). Postgres-basiert statt Redis/AMQP
-- (Projekt-Konvention, siehe nexarch-state.json techstack.job_queue) —
-- Zeilen ueberleben einen Neustart des Dispatcher-Prozesses unveraendert
-- (Akzeptanzkriterium 3).
CREATE TABLE notification_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
channel TEXT NOT NULL,
recipient TEXT NOT NULL,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'sent', 'failed')),
attempts INT NOT NULL DEFAULT 0,
max_attempts INT NOT NULL DEFAULT 5,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX notification_jobs_due_idx ON notification_jobs (status, next_attempt_at);
-2
View File
@@ -1,2 +0,0 @@
DROP TABLE alert_debounce_state;
DROP TABLE alert_rules;
-24
View File
@@ -1,24 +0,0 @@
-- OPS-05: Schwellwert-Regeln fuer Alerting auf den aus OPS-03 aggregierten
-- Metriken. Lebt wie config_values/notification_jobs (CFG-01/02) in der
-- zentralen Registry-DB — modulübergreifende Betriebskonfiguration, keine
-- Mandanten-Geschaeftsdaten.
CREATE TABLE alert_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
metric_name TEXT NOT NULL,
comparison TEXT NOT NULL CHECK (comparison IN ('gt', 'lt')),
threshold DOUBLE PRECISION NOT NULL,
label_filters JSONB NOT NULL DEFAULT '{}'::jsonb,
recipient TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Haelt fest, wann eine Regel zuletzt tatsaechlich einen Alarm ausgeloest
-- hat (Akzeptanzkriterium 3: Drosselung wiederholter Alarmierung fuer
-- denselben anhaltenden Zustand). rule_key kombiniert Regel-ID mit den
-- tatsaechlichen Label-Werten der ausloesenden Zeitreihe, damit dieselbe
-- Regel fuer unterschiedliche Tenants/Module unabhaengig gedrosselt wird.
CREATE TABLE alert_debounce_state (
rule_key TEXT PRIMARY KEY,
last_fired_at TIMESTAMPTZ NOT NULL
);
-1
View File
@@ -1 +0,0 @@
DROP TABLE metrics_sources;
-7
View File
@@ -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
);
-26
View File
@@ -1,26 +0,0 @@
#!/usr/bin/env bash
# Setzt die nexarch-Testumgebung zurueck: loescht die geteilte
# Registry-Tabelle "tenants" in der postgres-Wartungsdatenbank sowie alle
# tenant_*-Datenbanken. Noetig, weil verschiedene Feature-Branches
# unterschiedliche Registry-Schemata erwarten, aber dieselbe physische
# Postgres-Instanz auf dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/reset-test-env.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
ROLE="nexarch_test"
export PGPASSWORD="$PASS"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS tenants CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS audit_events CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_value_history CASCADE;"
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values CASCADE;"
dbs=$(psql -h localhost -U "$ROLE" -d postgres -tAc "SELECT datname FROM pg_database WHERE datname LIKE 'tenant\_%' ESCAPE '\'")
for db in $dbs; do
psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP DATABASE IF EXISTS \"${db}\";"
done
echo "Testumgebung zurueckgesetzt: registry-tabelle + $(echo "$dbs" | grep -c . || true) tenant-datenbank(en) entfernt."
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
# Ein-Kommando-Pruefung fuer den aktuellen Code-Stand auf dem Testhost:
# Registry+Tenant-DBs zuruecksetzen, dann build/vet/test in einem Rutsch.
# -p 1 ist Pflicht, da mehrere Pakete dieselbe physische Registry-Tabelle auf
# dem Testhost teilen (siehe [[project-nexarch-test-infra]]).
#
# Aufruf: NEXARCH_TEST_DB_PASSWORD=... ./scripts/run-checks.sh
set -euo pipefail
PASS="${NEXARCH_TEST_DB_PASSWORD:?Setze NEXARCH_TEST_DB_PASSWORD vor dem Aufruf}"
cd "$(dirname "$0")/.."
NEXARCH_TEST_DB_PASSWORD="$PASS" bash scripts/reset-test-env.sh
export TEST_ADMIN_DSN="postgresql://nexarch_test:${PASS}@localhost:5432/postgres?sslmode=disable"
echo "== go build =="
go build ./...
echo "== go vet =="
go vet ./...
echo "== go test (-p 1) =="
go test ./... -p 1 -count=1