diff --git a/cmd/statuspage-devserver/main.go b/cmd/statuspage-devserver/main.go new file mode 100644 index 0000000..229e3c2 --- /dev/null +++ b/cmd/statuspage-devserver/main.go @@ -0,0 +1,66 @@ +// statuspage-devserver stellt das OPS-02-Backend (internal/statuspage) fuer +// die Next.js-Statusseite bereit und startet den periodischen Poller. +// Getrennt von cmd/core aus demselben Grund wie die anderen *-devserver. +package main + +import ( + "context" + "log" + "net/http" + "os" + "strconv" + "time" + + "gitea.perlbach24.de/scripte/nexarch/internal/db" + "gitea.perlbach24.de/scripte/nexarch/internal/statuspage" +) + +func main() { + dsn := os.Getenv("NEXARCH_REGISTRY_DSN") + if dsn == "" { + log.Fatal("NEXARCH_REGISTRY_DSN nicht gesetzt") + } + addr := os.Getenv("NEXARCH_STATUSPAGE_LISTEN_ADDR") + if addr == "" { + addr = ":8084" + } + intervalSeconds := 10 + if v := os.Getenv("NEXARCH_STATUSPAGE_POLL_INTERVAL_SECONDS"); v != "" { + if parsed, err := strconv.Atoi(v); err == nil { + intervalSeconds = parsed + } + } + + ctx := context.Background() + pool, err := db.Connect(ctx, dsn) + if err != nil { + log.Fatalf("db: %v", err) + } + defer pool.Close() + + store := statuspage.NewStore(pool) + checker := statuspage.NewHTTPChecker(2 * time.Second) + poller := statuspage.NewPoller(store, checker) + go poller.Run(ctx, time.Duration(intervalSeconds)*time.Second) + + mux := http.NewServeMux() + mux.HandleFunc("/status/overview", withCORS(store.OverviewHandler)) + mux.HandleFunc("/status/history", withCORS(store.HistoryHandler)) + mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) }) + + log.Printf("statuspage-devserver listening on %s (poll-intervall: %ds)", addr, intervalSeconds) + log.Fatal(http.ListenAndServe(addr, mux)) +} + +func withCORS(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next(w, r) + } +} 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/internal/statuspage/statuspage.go b/internal/statuspage/statuspage.go new file mode 100644 index 0000000..c8c20b1 --- /dev/null +++ b/internal/statuspage/statuspage.go @@ -0,0 +1,273 @@ +// Package statuspage implementiert Core OPS-02: eine zentrale Statusseite, +// die den Health-Zustand aller registrierten Module aggregiert und den +// Verlauf vergangener Statusaenderungen speichert. Baut auf OPS-01 +// (internal/health) auf, indem es GENAU die dort etablierten +// Readiness-Endpunkte je Modul abfragt — dieses Paket dupliziert keine +// Health-Check-Logik, es aggregiert nur deren Ergebnisse ueber die Zeit. +package statuspage + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Status ist der aggregierte Zustand EINES Moduls zu einem Zeitpunkt. +type Status string + +const ( + StatusUp Status = "up" + StatusDown Status = "down" +) + +// Target ist ein zu ueberwachendes Modul mit seiner Readiness-URL +// (OPS-01-Endpunkt, z.B. "http://dms:8080/readyz"). Eigenstaendige +// Konfiguration statt Erweiterung von internal/moduleregistry.Module, um +// API-02 nicht anzufassen (Kein Umbau angrenzender Bereiche). +type Target struct { + Name string + HealthURL string +} + +// Store persistiert Ueberwachungsziele und den Verlauf ihrer +// Statusaenderungen. +type Store struct { + pool *pgxpool.Pool +} + +func NewStore(pool *pgxpool.Pool) *Store { + return &Store{pool: pool} +} + +// RegisterTarget traegt ein zu ueberwachendes Modul ein oder aktualisiert +// dessen URL (Akzeptanzkriterium 1: "aller registrierten Module"). +func (s *Store) RegisterTarget(ctx context.Context, t Target) error { + _, err := s.pool.Exec(ctx, ` + INSERT INTO status_targets (name, health_url) + VALUES ($1, $2) + ON CONFLICT (name) DO UPDATE SET health_url = $2 + `, t.Name, t.HealthURL) + if err != nil { + return fmt.Errorf("ueberwachungsziel speichern: %w", err) + } + return nil +} + +func (s *Store) ListTargets(ctx context.Context) ([]Target, error) { + rows, err := s.pool.Query(ctx, `SELECT name, health_url FROM status_targets ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("ueberwachungsziele auflisten: %w", err) + } + defer rows.Close() + + var out []Target + for rows.Next() { + var t Target + if err := rows.Scan(&t.Name, &t.HealthURL); err != nil { + return nil, fmt.Errorf("ueberwachungsziel lesen: %w", err) + } + out = append(out, t) + } + return out, rows.Err() +} + +// recordIfChanged schreibt NUR dann einen neuen Verlaufseintrag, wenn sich +// der Status seit dem letzten Eintrag geaendert hat (oder es der erste +// Eintrag ist) — der Verlauf zeigt Statusaenderungen (Akzeptanzkriterium 3), +// nicht jede einzelne Abfrage. +func (s *Store) recordIfChanged(ctx context.Context, name string, status Status) error { + var lastStatus string + err := s.pool.QueryRow(ctx, ` + SELECT status FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1 + `, name).Scan(&lastStatus) + if err != nil && err != pgx.ErrNoRows { + return fmt.Errorf("letzten status lesen: %w", err) + } + if err == nil && lastStatus == string(status) { + return nil // keine Aenderung, kein neuer Eintrag + } + + if _, err := s.pool.Exec(ctx, ` + INSERT INTO status_history (name, status, changed_at) VALUES ($1, $2, now()) + `, name, string(status)); err != nil { + return fmt.Errorf("statuseintrag schreiben: %w", err) + } + return nil +} + +// ModuleStatus ist der aktuelle Zustand EINES Moduls fuer die Uebersicht. +type ModuleStatus struct { + Name string `json:"name"` + Status Status `json:"status"` + LastChecked time.Time `json:"last_checked"` +} + +// Overview liefert den aktuellen (letzten bekannten) Status jedes +// registrierten Ziels (Akzeptanzkriterium 1). Ziele ohne jemals erfolgte +// Pruefung erscheinen mit Status "down" — ein Modul, ueber das nichts +// bekannt ist, gilt als nicht verfuegbar (Fail-Safe-Default), nicht als +// stillschweigend "ok". +func (s *Store) Overview(ctx context.Context) ([]ModuleStatus, error) { + targets, err := s.ListTargets(ctx) + if err != nil { + return nil, err + } + + out := make([]ModuleStatus, 0, len(targets)) + for _, t := range targets { + var status string + var changedAt time.Time + err := s.pool.QueryRow(ctx, ` + SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC LIMIT 1 + `, t.Name).Scan(&status, &changedAt) + if err == pgx.ErrNoRows { + out = append(out, ModuleStatus{Name: t.Name, Status: StatusDown}) + continue + } + if err != nil { + return nil, fmt.Errorf("aktuellen status lesen (%s): %w", t.Name, err) + } + out = append(out, ModuleStatus{Name: t.Name, Status: Status(status), LastChecked: changedAt}) + } + return out, nil +} + +// HistoryEntry ist EIN Verlaufseintrag (Akzeptanzkriterium 3). +type HistoryEntry struct { + Status Status `json:"status"` + ChangedAt time.Time `json:"changed_at"` +} + +func (s *Store) History(ctx context.Context, name string) ([]HistoryEntry, error) { + rows, err := s.pool.Query(ctx, ` + SELECT status, changed_at FROM status_history WHERE name = $1 ORDER BY changed_at DESC + `, name) + if err != nil { + return nil, fmt.Errorf("verlauf abfragen: %w", err) + } + defer rows.Close() + + var out []HistoryEntry + for rows.Next() { + var e HistoryEntry + var status string + if err := rows.Scan(&status, &e.ChangedAt); err != nil { + return nil, fmt.Errorf("verlaufseintrag lesen: %w", err) + } + e.Status = Status(status) + out = append(out, e) + } + return out, rows.Err() +} + +// HTTPChecker fragt die Readiness-URL eines Moduls ab (OPS-01-Endpunkt) und +// liefert StatusUp NUR bei HTTP 200 — jeder andere Statuscode ODER ein +// Netzwerkfehler/Timeout gilt als StatusDown. Ein einzelnes nicht +// erreichbares Modul liefert einen FEHLERFREIEN StatusDown-Wert statt eines +// Go-Errors, damit Poller.Run ein fehlerhaftes Modul niemals mit einem +// anderen verwechseln oder den gesamten Zyklus abbrechen kann +// (Akzeptanzkriterium 2). +type HTTPChecker struct { + Client *http.Client + Timeout time.Duration +} + +func NewHTTPChecker(timeout time.Duration) *HTTPChecker { + return &HTTPChecker{Client: &http.Client{}, Timeout: timeout} +} + +func (c *HTTPChecker) Check(ctx context.Context, url string) Status { + ctx, cancel := context.WithTimeout(ctx, c.Timeout) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return StatusDown + } + resp, err := c.Client.Do(req) + if err != nil { + return StatusDown + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return StatusUp + } + return StatusDown +} + +// Poller fragt periodisch alle Ziele ab und schreibt Statusaenderungen fort. +type Poller struct { + store *Store + checker *HTTPChecker +} + +func NewPoller(store *Store, checker *HTTPChecker) *Poller { + return &Poller{store: store, checker: checker} +} + +// PollOnce prueft ALLE Ziele in einem Durchlauf. Ein fehlschlagendes Ziel +// (Netzwerkfehler, Timeout, Nicht-200) wird als StatusDown vermerkt und +// haelt die Pruefung der UEBRIGEN Ziele nicht auf — die Schleife laeuft +// sequenziell weiter, kein Ziel kann ein anderes blockieren +// (Akzeptanzkriterium 2 / Pruefung 2). +func (p *Poller) PollOnce(ctx context.Context) error { + targets, err := p.store.ListTargets(ctx) + if err != nil { + return err + } + for _, t := range targets { + status := p.checker.Check(ctx, t.HealthURL) + if err := p.store.recordIfChanged(ctx, t.Name, status); err != nil { + // Ein Schreibfehler fuer EIN Ziel darf die Pruefung der anderen + // nicht verhindern — dieselbe Fail-Isolation wie bei einem + // unerreichbaren Modul. + continue + } + } + return nil +} + +// Run ruft PollOnce in festen Abstaenden auf, bis ctx beendet wird — +// dieselbe Konvention wie internal/tenant.Lifecycle.RunSweeper. +func (p *Poller) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + _ = p.PollOnce(ctx) + } + } +} + +// --- HTTP-Bindung fuer die Oberflaeche --- + +func (s *Store) OverviewHandler(w http.ResponseWriter, r *http.Request) { + overview, err := s.Overview(r.Context()) + writeJSONResult(w, overview, err) +} + +func (s *Store) HistoryHandler(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("name") + history, err := s.History(r.Context(), name) + writeJSONResult(w, history, err) +} + +func writeJSONResult(w http.ResponseWriter, body any, err error) { + w.Header().Set("Content-Type", "application/json") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(body) +} diff --git a/internal/statuspage/statuspage_test.go b/internal/statuspage/statuspage_test.go new file mode 100644 index 0000000..8ec3189 --- /dev/null +++ b/internal/statuspage/statuspage_test.go @@ -0,0 +1,198 @@ +package statuspage + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func setupTest(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 status_targets (name TEXT PRIMARY KEY, health_url TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, status TEXT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + `); err != nil { + t.Fatalf("schema: %v", err) + } + + cleanup := func() { pool.Close() } + return NewStore(pool), cleanup +} + +func uniqueName(prefix string) string { + return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano()) +} + +// Akzeptanzkriterium 1: Uebersicht zeigt den Status ALLER registrierten +// Module. +func TestOverview_ShowsAllRegisteredModules(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + defer up.Close() + + nameA, nameB := uniqueName("mod-a"), uniqueName("mod-b") + if err := store.RegisterTarget(ctx, Target{Name: nameA, HealthURL: up.URL}); err != nil { + t.Fatalf("target a: %v", err) + } + if err := store.RegisterTarget(ctx, Target{Name: nameB, HealthURL: up.URL}); err != nil { + t.Fatalf("target b: %v", err) + } + + poller := NewPoller(store, NewHTTPChecker(time.Second)) + if err := poller.PollOnce(ctx); err != nil { + t.Fatalf("poll: %v", err) + } + + overview, err := store.Overview(ctx) + if err != nil { + t.Fatalf("overview: %v", err) + } + // Overview() listet ALLE jemals registrierten Ziele (auch aus fruehreren + // Testlaeufen auf derselben geteilten Test-DB) — hier wird deshalb nur + // der Status von nameA/nameB geprueft, nicht jeder Eintrag der Tabelle. + statusByName := map[string]Status{} + for _, m := range overview { + statusByName[m.Name] = m.Status + } + if statusByName[nameA] != StatusUp { + t.Fatalf("modul %s: status = %s, want up", nameA, statusByName[nameA]) + } + if statusByName[nameB] != StatusUp { + t.Fatalf("modul %s: status = %s, want up", nameB, statusByName[nameB]) + } +} + +// Akzeptanzkriterium 2 + Pruefung 1/2: ein simulierter Ausfall eines Moduls +// wird sichtbar, das ANDERE Modul bleibt unbeeinflusst und die Pruefung +// beider laeuft trotzdem in einem Durchlauf durch (kein Totalausfall). +func TestPollOnce_IsolatesFailingModuleFromOthers(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + defer up.Close() + down := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusInternalServerError) })) + down.Close() // Server sofort schliessen -> Verbindung tatsaechlich unerreichbar (simulierter Ausfall) + + healthyName, downName := uniqueName("healthy"), uniqueName("down") + if err := store.RegisterTarget(ctx, Target{Name: healthyName, HealthURL: up.URL}); err != nil { + t.Fatalf("target healthy: %v", err) + } + if err := store.RegisterTarget(ctx, Target{Name: downName, HealthURL: down.URL}); err != nil { + t.Fatalf("target down: %v", err) + } + + poller := NewPoller(store, NewHTTPChecker(500*time.Millisecond)) + if err := poller.PollOnce(ctx); err != nil { + t.Fatalf("poll haette trotz einem ausgefallenen modul erfolgreich durchlaufen sollen: %v", err) + } + + overview, err := store.Overview(ctx) + if err != nil { + t.Fatalf("overview: %v", err) + } + statusByName := map[string]Status{} + for _, m := range overview { + statusByName[m.Name] = m.Status + } + if statusByName[healthyName] != StatusUp { + t.Fatalf("healthy modul: status = %s, want up", statusByName[healthyName]) + } + if statusByName[downName] != StatusDown { + t.Fatalf("ausgefallenes modul: status = %s, want down", statusByName[downName]) + } +} + +// Akzeptanzkriterium 3: Verlauf vergangener Statusaenderungen ist einsehbar. +func TestHistory_RecordsStatusTransitions(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueName("flaky") + + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + defer up.Close() + if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil { + t.Fatalf("target: %v", err) + } + + poller := NewPoller(store, NewHTTPChecker(time.Second)) + if err := poller.PollOnce(ctx); err != nil { + t.Fatalf("poll 1: %v", err) + } + + // Ziel wird "abgeschaltet" (URL zeigt jetzt auf einen bereits + // geschlossenen Server) -> naechster Poll erkennt den Statuswechsel. + closedURL := up.URL + up.Close() + if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: closedURL}); err != nil { + t.Fatalf("target aktualisieren: %v", err) + } + if err := poller.PollOnce(ctx); err != nil { + t.Fatalf("poll 2: %v", err) + } + + history, err := store.History(ctx, name) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(history) != 2 { + t.Fatalf("erwartet genau 2 statuswechsel (up -> down), habe %d: %+v", len(history), history) + } + if history[0].Status != StatusDown || history[1].Status != StatusUp { + t.Fatalf("erwartet verlauf [down, up] (neueste zuerst), habe: %+v", history) + } +} + +// Wiederholte Polls OHNE Statuswechsel duerfen den Verlauf nicht mit +// identischen Eintraegen fluten (siehe recordIfChanged). +func TestHistory_DoesNotDuplicateUnchangedStatus(t *testing.T) { + store, cleanup := setupTest(t) + defer cleanup() + ctx := context.Background() + name := uniqueName("stable") + + up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })) + defer up.Close() + if err := store.RegisterTarget(ctx, Target{Name: name, HealthURL: up.URL}); err != nil { + t.Fatalf("target: %v", err) + } + + poller := NewPoller(store, NewHTTPChecker(time.Second)) + for i := 0; i < 3; i++ { + if err := poller.PollOnce(ctx); err != nil { + t.Fatalf("poll %d: %v", i, err) + } + } + + history, err := store.History(ctx, name) + if err != nil { + t.Fatalf("history: %v", err) + } + if len(history) != 1 { + t.Fatalf("erwartet genau 1 eintrag trotz 3 unveraenderter polls, habe %d", len(history)) + } +} diff --git a/migrations/0006_status_page.down.sql b/migrations/0006_status_page.down.sql new file mode 100644 index 0000000..710bfb4 --- /dev/null +++ b/migrations/0006_status_page.down.sql @@ -0,0 +1,2 @@ +DROP TABLE status_history; +DROP TABLE status_targets; diff --git a/migrations/0006_status_page.up.sql b/migrations/0006_status_page.up.sql new file mode 100644 index 0000000..cf5278a --- /dev/null +++ b/migrations/0006_status_page.up.sql @@ -0,0 +1,15 @@ +-- Zentrale Statusseite (OPS-02, siehe core-kanban/tickets/OPS-02.md) — +-- ueberwachte Module und der Verlauf ihrer Statusaenderungen. +CREATE TABLE status_targets ( + name TEXT PRIMARY KEY, + health_url TEXT NOT NULL +); + +CREATE TABLE status_history ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + status TEXT NOT NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX status_history_name_idx ON status_history (name, changed_at DESC); diff --git a/scripts/reset-test-env.sh b/scripts/reset-test-env.sh index 45d49b6..d02d62b 100755 --- a/scripts/reset-test-env.sh +++ b/scripts/reset-test-env.sh @@ -1,16 +1,7 @@ #!/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" # Alle Registry-Tabellen, die von migrations/*.up.sql angelegt werden - @@ -40,10 +31,11 @@ psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EX psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS notification_channels CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS notification_jobs CASCADE;" psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS config_values CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS status_history CASCADE;" +psql -h localhost -U "$ROLE" -d postgres -v ON_ERROR_STOP=1 -c "DROP TABLE IF EXISTS status_targets 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 diff --git a/web/status-page/app/layout.tsx b/web/status-page/app/layout.tsx new file mode 100644 index 0000000..c39c3c7 --- /dev/null +++ b/web/status-page/app/layout.tsx @@ -0,0 +1,30 @@ +import { ThemeProvider, I18nProvider, ToastProvider, typography } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Systemstatus", +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + {children} + + + + + ); +} diff --git a/web/status-page/app/page.tsx b/web/status-page/app/page.tsx new file mode 100644 index 0000000..c1436a2 --- /dev/null +++ b/web/status-page/app/page.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { fetchOverview, fetchHistory, type ModuleStatus, type HistoryEntry } from "@/lib/api"; + +const POLL_INTERVAL_MS = 5000; + +export default function Page() { + const [modules, setModules] = useState([]); + const [error, setError] = useState(null); + const [selected, setSelected] = useState(null); + const [history, setHistory] = useState(null); + const [historyError, setHistoryError] = useState(null); + + useEffect(() => { + let cancelled = false; + + async function poll() { + try { + const data = await fetchOverview(); + if (!cancelled) { + setModules(data); + setError(null); + } + } catch (e: any) { + // Ein nicht antwortendes Backend darf die zuletzt bekannte Ansicht + // nicht loeschen und die Seite nicht unbedienbar machen + // (Akzeptanzkriterium 2) — nur eine Fehlermeldung anzeigen, alte + // Daten bleiben sichtbar. + if (!cancelled) { + setError(e.message ?? "Unbekannter Fehler beim Laden der Uebersicht"); + } + } + } + + poll(); + const id = setInterval(poll, POLL_INTERVAL_MS); + return () => { + cancelled = true; + clearInterval(id); + }; + }, []); + + async function openHistory(name: string) { + setSelected(name); + setHistoryError(null); + try { + const data = await fetchHistory(name); + setHistory(data); + } catch (e: any) { + setHistoryError(e.message ?? "Verlauf konnte nicht geladen werden"); + setHistory(null); + } + } + + const anyDown = modules.some((m) => m.status === "down"); + + return ( +
+

Systemstatus

+ + {error && ( +

+ Uebersicht konnte gerade nicht aktualisiert werden: {error}. Zuletzt bekannter Stand wird weiter angezeigt. +

+ )} + + {!error && anyDown && ( +

+ Mindestens ein Modul ist derzeit nicht erreichbar. +

+ )} + +
    + {modules.map((m) => ( +
  • +
    + {m.name} +
    + {m.status === "up" ? "Verfügbar" : "Nicht verfügbar"} + {m.last_checked && ` — zuletzt geprüft ${new Date(m.last_checked).toLocaleString("de-DE")}`} +
    +
    + +
  • + ))} + {modules.length === 0 && !error &&
  • Lade Modulstatus…
  • } +
+ + {selected && ( +
+

Verlauf: {selected}

+ {historyError && ( +

+ {historyError} +

+ )} + {history && ( +
    + {history.map((h, i) => ( +
  • + {new Date(h.changed_at).toLocaleString("de-DE")} — {h.status === "up" ? "verfügbar" : "nicht verfügbar"} +
  • + ))} + {history.length === 0 &&
  • Keine Statusänderungen bisher.
  • } +
+ )} + +
+ )} +
+ ); +} diff --git a/web/status-page/lib/api.ts b/web/status-page/lib/api.ts new file mode 100644 index 0000000..660b80c --- /dev/null +++ b/web/status-page/lib/api.ts @@ -0,0 +1,44 @@ +// Duenner Client des OPS-02-Backends (internal/statuspage) — keine eigene +// Aggregations-/Polling-Logik im Frontend. +export type ModuleStatus = { + name: string; + status: "up" | "down"; + last_checked?: string; +}; + +export type HistoryEntry = { + status: "up" | "down"; + changed_at: string; +}; + +function apiBase(): string { + const base = process.env.NEXT_PUBLIC_STATUSPAGE_API_URL; + if (!base) { + throw new Error( + "NEXT_PUBLIC_STATUSPAGE_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)" + ); + } + return base; +} + +// fetchOverview holt den Status EINES Moduls unabhaengig vom Erfolg der +// anderen — ein Netzwerkfehler beim Abruf der Gesamtuebersicht wird vom +// Aufrufer (Page-Komponente) abgefangen, sodass ein nicht antwortendes +// Backend die Seite nicht zum Absturz bringt (Akzeptanzkriterium 2). +export async function fetchOverview(): Promise { + const res = await fetch(`${apiBase()}/status/overview`, { cache: "no-store" }); + if (!res.ok) { + throw new Error(`Uebersicht konnte nicht geladen werden (${res.status})`); + } + return res.json(); +} + +export async function fetchHistory(name: string): Promise { + const res = await fetch(`${apiBase()}/status/history?name=${encodeURIComponent(name)}`, { + cache: "no-store", + }); + if (!res.ok) { + throw new Error(`Verlauf konnte nicht geladen werden (${res.status})`); + } + return res.json(); +} diff --git a/web/status-page/next.config.mjs b/web/status-page/next.config.mjs new file mode 100644 index 0000000..fa7f635 --- /dev/null +++ b/web/status-page/next.config.mjs @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + // @nexarch/shl liegt als file:-Dependency mit TS-Quellen in node_modules — + // Next.js transpiliert node_modules standardmäßig nicht, siehe web/shl/README.md. + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/status-page/package.json b/web/status-page/package.json new file mode 100644 index 0000000..cd7aab3 --- /dev/null +++ b/web/status-page/package.json @@ -0,0 +1,22 @@ +{ + "name": "nexarch-status-page", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "next": "14.2.35", + "react": "18.3.1", + "react-dom": "18.3.1" + }, + "devDependencies": { + "@types/node": "20.14.9", + "@types/react": "18.3.3", + "@types/react-dom": "18.3.0", + "typescript": "5.5.3" + } +} diff --git a/web/status-page/tsconfig.json b/web/status-page/tsconfig.json new file mode 100644 index 0000000..26caf44 --- /dev/null +++ b/web/status-page/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}