OPS-02: zentrale-statusseite (backend: poller + aggregierte uebersicht + verlauf)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user