diff --git a/internal/health/checks.go b/internal/health/checks.go new file mode 100644 index 0000000..8f9f87b --- /dev/null +++ b/internal/health/checks.go @@ -0,0 +1,25 @@ +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 + } +} diff --git a/internal/health/handler.go b/internal/health/handler.go new file mode 100644 index 0000000..56be56a --- /dev/null +++ b/internal/health/handler.go @@ -0,0 +1,49 @@ +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) +} diff --git a/internal/health/health.go b/internal/health/health.go new file mode 100644 index 0000000..da7bf1e --- /dev/null +++ b/internal/health/health.go @@ -0,0 +1,86 @@ +// 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 +} diff --git a/internal/health/health_test.go b/internal/health/health_test.go new file mode 100644 index 0000000..16f8b51 --- /dev/null +++ b/internal/health/health_test.go @@ -0,0 +1,161 @@ +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"]) + } +} diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh index 5bc2bc5..fab5903 100755 --- a/scripts/reset-test-env.sh +++ b/scripts/reset-test-env.sh @@ -1,23 +1,11 @@ #!/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;" - 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." diff --git a/scripts/run-checks.sh b/scripts/run-checks.sh index 83f26b6..1c28c3c 100755 --- a/scripts/run-checks.sh +++ b/scripts/run-checks.sh @@ -1,24 +1,12 @@ #!/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