From dff760e1ba864e3b7347885a236c5ba6f28fb1f4 Mon Sep 17 00:00:00 2001 From: sysops Date: Fri, 28 Aug 2026 08:19:40 +0200 Subject: [PATCH] OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf) --- internal/statuspage/statuspage.go | 273 +++++++++++++++++++++++++ internal/statuspage/statuspage_test.go | 196 ++++++++++++++++++ migrations/0006_status_page.down.sql | 2 + migrations/0006_status_page.up.sql | 15 ++ 4 files changed, 486 insertions(+) create mode 100644 internal/statuspage/statuspage.go create mode 100644 internal/statuspage/statuspage_test.go create mode 100644 migrations/0006_status_page.down.sql create mode 100644 migrations/0006_status_page.up.sql 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..0349e9a --- /dev/null +++ b/internal/statuspage/statuspage_test.go @@ -0,0 +1,196 @@ +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 EXTENSION IF NOT EXISTS pgcrypto; + 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) + } + found := map[string]bool{} + for _, m := range overview { + found[m.Name] = true + if m.Status != StatusUp { + t.Fatalf("modul %s: status = %s, want up", m.Name, m.Status) + } + } + if !found[nameA] || !found[nameB] { + t.Fatalf("erwartet beide module in der uebersicht, habe: %+v", overview) + } +} + +// 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);