erste module drin.
zwischenstand: Architektur aktualisiert, TOFU implementiert,
This commit is contained in:
1 parent
9d5789c5d7
commit
c8df9d7651
41 files changed
+6455
-36
No files matched your search
@@ -0,0 +1,197 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrUnexpectedResponse: Der Server antwortet nicht mit 200 und text/event-stream.
|
||||
ErrUnexpectedResponse = errors.New("stream: unerwartete Server-Antwort")
|
||||
// ErrIdleTimeout: Innerhalb von IdleTimeout kam nichts an, auch kein Keepalive.
|
||||
ErrIdleTimeout = errors.New("stream: Server schweigt (Idle-Timeout)")
|
||||
// ErrStreamClosed: Der Server hat den Stream beendet.
|
||||
ErrStreamClosed = errors.New("stream: vom Server beendet")
|
||||
)
|
||||
|
||||
// Client ist die Agent-Seite des Streams.
|
||||
type Client struct {
|
||||
// URL des Stream-Endpunkts.
|
||||
URL string
|
||||
// HTTPClient für die Verbindung (Default http.DefaultClient). Er darf
|
||||
// kein Timeout gesetzt haben, sonst bricht er den langlebigen Stream ab;
|
||||
// die Erkennung toter Verbindungen übernimmt IdleTimeout.
|
||||
HTTPClient *http.Client
|
||||
// Prepare wird vor jedem Verbindungsaufbau mit dem Request aufgerufen,
|
||||
// z. B. um ihn zu signieren.
|
||||
Prepare func(*http.Request) error
|
||||
// IdleTimeout: Kommt so lange gar nichts an, gilt die Verbindung als tot
|
||||
// (Default 90s = 2 × Server-Keepalive).
|
||||
IdleTimeout time.Duration
|
||||
// MinBackoff und MaxBackoff begrenzen die Wartezeit zwischen
|
||||
// Verbindungsversuchen in Run (Default 1s bzw. 1min).
|
||||
MinBackoff, MaxBackoff time.Duration
|
||||
// OnError wird in Run vor jeder Wartezeit mit dem Grund aufgerufen.
|
||||
OnError func(err error, retryIn time.Duration)
|
||||
|
||||
sleep func(ctx context.Context, d time.Duration) error // für Tests ersetzbar
|
||||
}
|
||||
|
||||
func (c *Client) idleTimeout() time.Duration { return orDefault(c.IdleTimeout, 90*time.Second) }
|
||||
func (c *Client) minBackoff() time.Duration { return orDefault(c.MinBackoff, time.Second) }
|
||||
func (c *Client) maxBackoff() time.Duration { return orDefault(c.MaxBackoff, time.Minute) }
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return http.DefaultClient
|
||||
}
|
||||
|
||||
// handlerError markiert Fehler des Handlers, damit Run sie nicht als
|
||||
// Verbindungsproblem behandelt.
|
||||
type handlerError struct{ err error }
|
||||
|
||||
func (e *handlerError) Error() string { return e.err.Error() }
|
||||
func (e *handlerError) Unwrap() error { return e.err }
|
||||
|
||||
// Connect baut genau eine Verbindung auf und ruft handle für jedes Event auf,
|
||||
// bis die Verbindung endet. Gibt handle einen Fehler zurück, endet Connect
|
||||
// mit diesem Fehler.
|
||||
func (c *Client) Connect(ctx context.Context, handle func(Event) error) error {
|
||||
_, err := c.connect(ctx, handle)
|
||||
var he *handlerError
|
||||
if errors.As(err, &he) {
|
||||
return he.err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// connect meldet zusätzlich, ob der Stream zustande kam (für das
|
||||
// Zurücksetzen des Backoffs).
|
||||
func (c *Client) connect(ctx context.Context, handle func(Event) error) (connected bool, err error) {
|
||||
ctx, cancel := context.WithCancelCause(ctx)
|
||||
defer cancel(nil)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.URL, nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
req.Header.Set("Accept", "text/event-stream")
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
if c.Prepare != nil {
|
||||
if err := c.Prepare(req); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return false, fmt.Errorf("%w: Status %d", ErrUnexpectedResponse, resp.StatusCode)
|
||||
}
|
||||
if mt, _, _ := mime.ParseMediaType(resp.Header.Get("Content-Type")); mt != "text/event-stream" {
|
||||
return false, fmt.Errorf("%w: Content-Type %q", ErrUnexpectedResponse, resp.Header.Get("Content-Type"))
|
||||
}
|
||||
|
||||
idle := c.idleTimeout()
|
||||
timer := time.AfterFunc(idle, func() { cancel(ErrIdleTimeout) })
|
||||
defer timer.Stop()
|
||||
dec := NewDecoder(&activityReader{r: resp.Body, timer: timer, idle: idle})
|
||||
|
||||
for {
|
||||
ev, err := dec.Next()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return true, context.Cause(ctx)
|
||||
}
|
||||
if errors.Is(err, io.EOF) {
|
||||
return true, ErrStreamClosed
|
||||
}
|
||||
return true, err
|
||||
}
|
||||
if err := handle(ev); err != nil {
|
||||
return true, &handlerError{err}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// activityReader setzt den Idle-Timer bei jedem empfangenen Byte zurück –
|
||||
// Keepalives zählen also mit.
|
||||
type activityReader struct {
|
||||
r io.Reader
|
||||
timer *time.Timer
|
||||
idle time.Duration
|
||||
}
|
||||
|
||||
func (a *activityReader) Read(p []byte) (int, error) {
|
||||
n, err := a.r.Read(p)
|
||||
if n > 0 {
|
||||
a.timer.Reset(a.idle)
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Run hält die Verbindung dauerhaft aufrecht: Nach jedem Verbindungsende
|
||||
// wird mit exponentiellem Backoff neu verbunden; nach einer erfolgreich
|
||||
// aufgebauten Verbindung beginnt das Backoff wieder bei MinBackoff. Run endet
|
||||
// nur, wenn ctx endet (ctx.Err()) oder handle einen Fehler liefert.
|
||||
func (c *Client) Run(ctx context.Context, handle func(Event) error) error {
|
||||
sleep := c.sleep
|
||||
if sleep == nil {
|
||||
sleep = sleepCtx
|
||||
}
|
||||
attempt := 0
|
||||
for {
|
||||
connected, err := c.connect(ctx, handle)
|
||||
if ctx.Err() != nil {
|
||||
return ctx.Err()
|
||||
}
|
||||
var he *handlerError
|
||||
if errors.As(err, &he) {
|
||||
return he.err
|
||||
}
|
||||
if connected {
|
||||
attempt = 0
|
||||
}
|
||||
d := backoff(attempt, c.minBackoff(), c.maxBackoff())
|
||||
attempt++
|
||||
if c.OnError != nil {
|
||||
c.OnError(err, d)
|
||||
}
|
||||
if err := sleep(ctx, d); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// backoff liefert minD · 2^attempt, höchstens maxD.
|
||||
func backoff(attempt int, minD, maxD time.Duration) time.Duration {
|
||||
d := minD
|
||||
for range attempt {
|
||||
if d >= maxD/2 {
|
||||
return maxD
|
||||
}
|
||||
d *= 2
|
||||
}
|
||||
return min(d, maxD)
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) error {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-t.C:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestBackoff(t *testing.T) {
|
||||
const minD, maxD = time.Second, 60 * time.Second
|
||||
tests := map[int]time.Duration{
|
||||
0: time.Second,
|
||||
1: 2 * time.Second,
|
||||
5: 32 * time.Second,
|
||||
6: 60 * time.Second,
|
||||
100: 60 * time.Second,
|
||||
}
|
||||
for attempt, want := range tests {
|
||||
if got := backoff(attempt, minD, maxD); got != want {
|
||||
t.Errorf("backoff(%d) = %v, erwartet %v", attempt, got, want)
|
||||
}
|
||||
}
|
||||
if got := backoff(3, 5*time.Second, 5*time.Second); got != 5*time.Second {
|
||||
t.Errorf("min == max: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_Defaults(t *testing.T) {
|
||||
var c Client
|
||||
if got := c.idleTimeout(); got != 90*time.Second {
|
||||
t.Errorf("IdleTimeout-Default = %v, erwartet 90s (2 × Keepalive)", got)
|
||||
}
|
||||
if got := c.minBackoff(); got != time.Second {
|
||||
t.Errorf("MinBackoff-Default = %v", got)
|
||||
}
|
||||
if got := c.maxBackoff(); got != time.Minute {
|
||||
t.Errorf("MaxBackoff-Default = %v", got)
|
||||
}
|
||||
if c.httpClient() != http.DefaultClient {
|
||||
t.Error("HTTPClient-Default sollte http.DefaultClient sein")
|
||||
}
|
||||
}
|
||||
|
||||
// Ende-zu-Ende: Client verbindet sich über Run mit einem Hub, Prepare setzt
|
||||
// Authentifizierung, ein vom Hub gesendetes Event kommt beim Handler an.
|
||||
func TestClient_ReceivesEvents(t *testing.T) {
|
||||
h := &Hub{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer geheim" {
|
||||
http.Error(w, "nein", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if r.Header.Get("Accept") != "text/event-stream" {
|
||||
http.Error(w, "falscher Accept", http.StatusNotAcceptable)
|
||||
return
|
||||
}
|
||||
h.Serve(w, r, "node-1")
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(h.Close)
|
||||
|
||||
c := &Client{
|
||||
URL: srv.URL,
|
||||
Prepare: func(r *http.Request) error { r.Header.Set("Authorization", "Bearer geheim"); return nil },
|
||||
}
|
||||
got := make(chan Event, 1)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.Run(ctx, func(ev Event) error { got <- ev; return nil }) }()
|
||||
|
||||
eventually(t, "Client verbunden", func() bool { return h.Connected("node-1") })
|
||||
if err := h.Send("node-1", Event{Type: EventNodeCommand}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
select {
|
||||
case ev := <-got:
|
||||
if ev.Type != EventNodeCommand || ev.NodeID != "node-1" {
|
||||
t.Errorf("unerwartetes Event: %+v", ev)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("kein Event empfangen")
|
||||
}
|
||||
cancel()
|
||||
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Run: erwartet context.Canceled, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_UnexpectedResponse(t *testing.T) {
|
||||
tests := map[string]http.HandlerFunc{
|
||||
"Status 401": func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "nein", http.StatusUnauthorized)
|
||||
},
|
||||
"falscher Content-Type": func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
w.Write([]byte("hallo"))
|
||||
},
|
||||
}
|
||||
for name, handler := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(handler)
|
||||
defer srv.Close()
|
||||
c := &Client{URL: srv.URL}
|
||||
err := c.Connect(context.Background(), func(Event) error { return nil })
|
||||
if !errors.Is(err, ErrUnexpectedResponse) {
|
||||
t.Errorf("erwartet ErrUnexpectedResponse, bekommen %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sseHandler sendet nur die Header und hält die Verbindung ohne Daten offen.
|
||||
func silentSSE(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.(http.Flusher).Flush()
|
||||
<-r.Context().Done()
|
||||
}
|
||||
|
||||
func TestClient_IdleTimeout(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(silentSSE))
|
||||
defer srv.Close()
|
||||
c := &Client{URL: srv.URL, IdleTimeout: 50 * time.Millisecond}
|
||||
|
||||
start := time.Now()
|
||||
err := c.Connect(context.Background(), func(Event) error { return nil })
|
||||
if !errors.Is(err, ErrIdleTimeout) {
|
||||
t.Fatalf("erwartet ErrIdleTimeout, bekommen %v", err)
|
||||
}
|
||||
if d := time.Since(start); d > time.Second {
|
||||
t.Errorf("Timeout griff erst nach %v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_KeepAlivePreventsIdleTimeout(t *testing.T) {
|
||||
h := &Hub{KeepAlive: 10 * time.Millisecond}
|
||||
srv, _ := hubServer(t, h)
|
||||
c := &Client{URL: srv.URL + "?node=n", IdleTimeout: 60 * time.Millisecond}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- c.Connect(ctx, func(Event) error { return nil }) }()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
t.Fatalf("Verbindung trotz Keepalives beendet: %v", err)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
}
|
||||
cancel()
|
||||
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("erwartet context.Canceled, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ServerClosesStream(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
||||
w.Write([]byte("data: " + evJSON + "\n\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
var got []Event
|
||||
err := (&Client{URL: srv.URL}).Connect(context.Background(), func(ev Event) error {
|
||||
got = append(got, ev)
|
||||
return nil
|
||||
})
|
||||
if !errors.Is(err, ErrStreamClosed) {
|
||||
t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, []Event{evWant}) {
|
||||
t.Errorf("Events: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_PrepareError(t *testing.T) {
|
||||
errSign := errors.New("Signatur fehlgeschlagen")
|
||||
c := &Client{URL: "http://127.0.0.1:1", Prepare: func(*http.Request) error { return errSign }}
|
||||
if err := c.Connect(context.Background(), nil); !errors.Is(err, errSign) {
|
||||
t.Errorf("erwartet Prepare-Fehler, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ConnectionRefused(t *testing.T) {
|
||||
srv := httptest.NewServer(http.NotFoundHandler())
|
||||
url := srv.URL
|
||||
srv.Close()
|
||||
if err := (&Client{URL: url}).Connect(context.Background(), nil); err == nil {
|
||||
t.Error("erwartet Verbindungsfehler")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_HandlerErrorStopsRun(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requests.Add(1)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte("data: " + evJSON + "\n\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
errStop := errors.New("Handler bricht ab")
|
||||
err := (&Client{URL: srv.URL}).Run(context.Background(), func(Event) error { return errStop })
|
||||
if !errors.Is(err, errStop) {
|
||||
t.Errorf("erwartet Handler-Fehler, bekommen %v", err)
|
||||
}
|
||||
if n := requests.Load(); n != 1 {
|
||||
t.Errorf("%d Verbindungsversuche, erwartet 1 (kein Reconnect nach Handler-Fehler)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// Ablauf: 503, 503, erfolgreicher Stream (endet), danach nur noch 503.
|
||||
// Erwartet: exponentielles Backoff, Reset nach erfolgreicher Verbindung,
|
||||
// Deckelung bei MaxBackoff.
|
||||
func TestClient_RunRetriesWithBackoff(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if requests.Add(1) == 3 {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte("data: " + evJSON + "\n\n"))
|
||||
return
|
||||
}
|
||||
http.Error(w, "später", http.StatusServiceUnavailable)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
var mu sync.Mutex
|
||||
var delays []time.Duration
|
||||
var reported int
|
||||
var events atomic.Int32
|
||||
c := &Client{
|
||||
URL: srv.URL,
|
||||
MinBackoff: 10 * time.Millisecond,
|
||||
MaxBackoff: 40 * time.Millisecond,
|
||||
OnError: func(err error, retryIn time.Duration) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err == nil {
|
||||
t.Error("OnError ohne Fehler aufgerufen")
|
||||
}
|
||||
reported++
|
||||
},
|
||||
sleep: func(ctx context.Context, d time.Duration) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
delays = append(delays, d)
|
||||
if len(delays) == 6 {
|
||||
cancel()
|
||||
return ctx.Err()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
err := c.Run(ctx, func(Event) error { events.Add(1); return nil })
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("erwartet context.Canceled, bekommen %v", err)
|
||||
}
|
||||
ms := time.Millisecond
|
||||
want := []time.Duration{10 * ms, 20 * ms, 10 * ms, 20 * ms, 40 * ms, 40 * ms}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !reflect.DeepEqual(delays, want) {
|
||||
t.Errorf("Backoff-Folge = %v, erwartet %v", delays, want)
|
||||
}
|
||||
if reported != 6 {
|
||||
t.Errorf("OnError %d-mal aufgerufen, erwartet 6", reported)
|
||||
}
|
||||
if events.Load() != 1 {
|
||||
t.Errorf("%d Events empfangen, erwartet 1", events.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_ConnectReturnsHandlerError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte("data: " + evJSON + "\n\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
errStop := errors.New("stopp")
|
||||
err := (&Client{URL: srv.URL}).Connect(context.Background(), func(Event) error { return errStop })
|
||||
if err != errStop {
|
||||
t.Errorf("erwartet den unverpackten Handler-Fehler, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClient_CustomHTTPClient(t *testing.T) {
|
||||
var used atomic.Bool
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
}))
|
||||
defer srv.Close()
|
||||
hc := srv.Client()
|
||||
base := hc.Transport
|
||||
hc.Transport = roundTripFunc(func(r *http.Request) (*http.Response, error) {
|
||||
used.Store(true)
|
||||
return base.RoundTrip(r)
|
||||
})
|
||||
|
||||
err := (&Client{URL: srv.URL, HTTPClient: hc}).Connect(context.Background(), nil)
|
||||
if !errors.Is(err, ErrStreamClosed) {
|
||||
t.Errorf("erwartet ErrStreamClosed, bekommen %v", err)
|
||||
}
|
||||
if !used.Load() {
|
||||
t.Error("eigener HTTPClient wurde nicht verwendet")
|
||||
}
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
|
||||
|
||||
func TestSleepCtx(t *testing.T) {
|
||||
if err := sleepCtx(context.Background(), time.Millisecond); err != nil {
|
||||
t.Errorf("abgelaufener Timer: %v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
start := time.Now()
|
||||
if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("erwartet context.Canceled, bekommen %v", err)
|
||||
}
|
||||
if time.Since(start) > time.Second {
|
||||
t.Error("sleepCtx hat trotz Abbruch gewartet")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MaxEventSize begrenzt die Datenmenge eines einzelnen Events.
|
||||
const MaxEventSize = 1 << 20
|
||||
|
||||
// ErrEventTooLarge meldet ein Event über MaxEventSize.
|
||||
var ErrEventTooLarge = errors.New("stream: Event zu groß")
|
||||
|
||||
// Decoder liest Events aus einem SSE-Stream. Kommentarzeilen (Keepalives)
|
||||
// und unbekannte Felder werden übersprungen; maßgeblich ist allein das JSON
|
||||
// in den data-Zeilen.
|
||||
type Decoder struct {
|
||||
sc *bufio.Scanner
|
||||
}
|
||||
|
||||
// NewDecoder liest aus r.
|
||||
func NewDecoder(r io.Reader) *Decoder {
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 4096), MaxEventSize+1024)
|
||||
return &Decoder{sc: sc}
|
||||
}
|
||||
|
||||
// Next liefert das nächste Event. Am sauberen Stream-Ende kommt io.EOF, endet
|
||||
// der Stream mitten in einem Event io.ErrUnexpectedEOF.
|
||||
func (d *Decoder) Next() (Event, error) {
|
||||
var data strings.Builder
|
||||
pending := false
|
||||
for d.sc.Scan() {
|
||||
line := d.sc.Text() // ScanLines entfernt auch ein \r vor dem \n
|
||||
switch {
|
||||
case line == "":
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
return parseEvent(data.String())
|
||||
case strings.HasPrefix(line, ":"):
|
||||
// Kommentar / Keepalive
|
||||
case strings.HasPrefix(line, "data:"):
|
||||
v := strings.TrimPrefix(strings.TrimPrefix(line, "data:"), " ")
|
||||
if pending {
|
||||
data.WriteByte('\n')
|
||||
}
|
||||
data.WriteString(v)
|
||||
pending = true
|
||||
if data.Len() > MaxEventSize {
|
||||
return Event{}, ErrEventTooLarge
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := d.sc.Err(); err != nil {
|
||||
if errors.Is(err, bufio.ErrTooLong) {
|
||||
return Event{}, ErrEventTooLarge
|
||||
}
|
||||
return Event{}, err
|
||||
}
|
||||
if pending {
|
||||
return Event{}, io.ErrUnexpectedEOF
|
||||
}
|
||||
return Event{}, io.EOF
|
||||
}
|
||||
|
||||
func parseEvent(data string) (Event, error) {
|
||||
var ev Event
|
||||
if err := json.Unmarshal([]byte(data), &ev); err != nil {
|
||||
return Event{}, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
|
||||
}
|
||||
if ev.Type == "" {
|
||||
return Event{}, fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const evJSON = `{"event_id":"evt_1","timestamp":1,"type":"node_command","node_id":"n1","payload":{"action":"drain"}}`
|
||||
|
||||
var evWant = Event{
|
||||
ID: "evt_1",
|
||||
Timestamp: 1,
|
||||
Type: EventNodeCommand,
|
||||
NodeID: "n1",
|
||||
Payload: json.RawMessage(`{"action":"drain"}`),
|
||||
}
|
||||
|
||||
func decodeAll(t *testing.T, input string) ([]Event, error) {
|
||||
t.Helper()
|
||||
dec := NewDecoder(strings.NewReader(input))
|
||||
var out []Event
|
||||
for {
|
||||
ev, err := dec.Next()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoder(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want []Event
|
||||
}{
|
||||
{"einzelnes Event", "id: evt_1\nevent: node_command\ndata: " + evJSON + "\n\n", []Event{evWant}},
|
||||
{"Keepalives werden übersprungen", ":\n\n:\n\ndata: " + evJSON + "\n\n:\n\n", []Event{evWant}},
|
||||
{"CRLF-Zeilenenden", "id: evt_1\r\ndata: " + evJSON + "\r\n\r\n", []Event{evWant}},
|
||||
{"data ohne Leerzeichen", "data:" + evJSON + "\n\n", []Event{evWant}},
|
||||
{
|
||||
"mehrere data-Zeilen",
|
||||
"data: {\"event_id\":\"evt_1\",\"timestamp\":1,\ndata: \"type\":\"node_command\",\"node_id\":\"n1\",\ndata: \"payload\":{\"action\":\"drain\"}}\n\n",
|
||||
[]Event{evWant},
|
||||
},
|
||||
{"unbekannte Felder ignoriert", "retry: 1000\nfoo: bar\ndata: " + evJSON + "\n\n", []Event{evWant}},
|
||||
{"Leerzeilen ohne Daten", "\n\n\nevent: x\n\n", nil},
|
||||
{"zwei Events", "data: " + evJSON + "\n\ndata: " + evJSON + "\n\n", []Event{evWant, evWant}},
|
||||
{"leerer Stream", "", nil},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := decodeAll(t, tt.input)
|
||||
if !errors.Is(err, io.EOF) {
|
||||
t.Fatalf("erwartet io.EOF am Ende, bekommen %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("Events weichen ab\n got: %+v\nwant: %+v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecoder_Errors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want error
|
||||
}{
|
||||
{"ungültiges JSON", "data: {kaputt\n\n", ErrInvalidEvent},
|
||||
{"Event ohne Typ", "data: {\"event_id\":\"evt_1\"}\n\n", ErrInvalidEvent},
|
||||
{"abgeschnittenes Event", "data: " + evJSON + "\n", io.ErrUnexpectedEOF},
|
||||
{"Zeile zu lang", "data: " + strings.Repeat("x", MaxEventSize+1) + "\n\n", ErrEventTooLarge},
|
||||
{"Summe der data-Zeilen zu lang", strings.Repeat("data: "+strings.Repeat("x", MaxEventSize/4)+"\n", 5) + "\n", ErrEventTooLarge},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := decodeAll(t, tt.input)
|
||||
if !errors.Is(err, tt.want) {
|
||||
t.Errorf("erwartet %v, bekommen %v", tt.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeDecodeRoundTrip(t *testing.T) {
|
||||
ev, err := NewEvent(EventServiceState, map[string]any{"labels": map[string]string{"traefik.enable": "true"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev.NodeID = "n1"
|
||||
frame, err := encode(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(string(frame), "id: "+ev.ID+"\nevent: service_state\ndata: ") || !strings.HasSuffix(string(frame), "\n\n") {
|
||||
t.Errorf("unerwarteter SSE-Rahmen: %q", frame)
|
||||
}
|
||||
got, err := NewDecoder(strings.NewReader(string(frame))).Next()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, ev) {
|
||||
t.Errorf("Round-Trip weicht ab\n got: %+v\nwant: %+v", got, ev)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
// Package stream implementiert den langlebigen Kanal vom Sultan-Server zum
|
||||
// Agenten als Server-Sent Events (SSE) über eine ausgehende HTTP-Verbindung
|
||||
// des Agenten.
|
||||
//
|
||||
// Der Server (Hub) hält pro Knoten einen offenen Stream, schickt Events sofort
|
||||
// und sendet in Ruhephasen alle 45s die SSE-Kommentarzeile ":\n\n" als
|
||||
// Keepalive, damit NAT-/Conntrack-Einträge nicht verfallen. Der Agent (Client)
|
||||
// liest den Stream, erkennt einen toten Server, wenn zu lange gar nichts
|
||||
// ankommt (auch kein Keepalive), und verbindet sich mit exponentiellem
|
||||
// Backoff neu.
|
||||
//
|
||||
// Die Gegenrichtung (Agent -> Server, z. B. service_state) ist nicht Teil
|
||||
// dieses Streams; sie läuft über eigene Requests.
|
||||
package stream
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EventType ist der Typ eines Events gemäß Spec Abschnitt 4.2.
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventServiceState EventType = "service_state"
|
||||
EventLeaseRenew EventType = "lease_renew"
|
||||
EventNodeCommand EventType = "node_command"
|
||||
EventError EventType = "error"
|
||||
)
|
||||
|
||||
// ErrInvalidEvent meldet ein Event, das sich nicht senden oder lesen lässt.
|
||||
var ErrInvalidEvent = errors.New("stream: ungültiges Event")
|
||||
|
||||
// Event ist die Nachricht auf dem Stream.
|
||||
type Event struct {
|
||||
ID string `json:"event_id"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Type EventType `json:"type"`
|
||||
NodeID string `json:"node_id"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// NewEvent erzeugt ein Event mit neuer ID, aktuellem Zeitstempel und der als
|
||||
// JSON serialisierten payload (nil ergibt keine Payload). NodeID setzt der Hub
|
||||
// beim Senden.
|
||||
func NewEvent(t EventType, payload any) (Event, error) {
|
||||
if t == "" {
|
||||
return Event{}, fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
|
||||
}
|
||||
ev := Event{ID: newEventID(), Timestamp: time.Now().Unix(), Type: t}
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return Event{}, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
|
||||
}
|
||||
ev.Payload = b
|
||||
}
|
||||
return ev, nil
|
||||
}
|
||||
|
||||
func newEventID() string {
|
||||
var b [16]byte
|
||||
rand.Read(b[:])
|
||||
return "evt_" + hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// encode erzeugt den SSE-Rahmen für ev.
|
||||
func encode(ev Event) ([]byte, error) {
|
||||
data, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrInvalidEvent, err)
|
||||
}
|
||||
return fmt.Appendf(nil, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data), nil
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewEvent(t *testing.T) {
|
||||
before := time.Now().Unix()
|
||||
ev, err := NewEvent(EventNodeCommand, map[string]string{"action": "drain"})
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(ev.ID, "evt_") || len(ev.ID) <= len("evt_") {
|
||||
t.Errorf("ID = %q, erwartet Präfix evt_", ev.ID)
|
||||
}
|
||||
if ev.Timestamp < before || ev.Timestamp > time.Now().Unix() {
|
||||
t.Errorf("Timestamp = %d liegt nicht im Testzeitraum", ev.Timestamp)
|
||||
}
|
||||
if ev.Type != EventNodeCommand {
|
||||
t.Errorf("Type = %q", ev.Type)
|
||||
}
|
||||
if ev.NodeID != "" {
|
||||
t.Errorf("NodeID = %q, erwartet leer (setzt der Hub)", ev.NodeID)
|
||||
}
|
||||
if string(ev.Payload) != `{"action":"drain"}` {
|
||||
t.Errorf("Payload = %s", ev.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEvent_NilPayload(t *testing.T) {
|
||||
ev, err := NewEvent(EventLeaseRenew, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter Fehler: %v", err)
|
||||
}
|
||||
if ev.Payload != nil {
|
||||
t.Errorf("Payload = %s, erwartet nil", ev.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEvent_Errors(t *testing.T) {
|
||||
if _, err := NewEvent("", nil); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Errorf("leerer Typ: erwartet ErrInvalidEvent, bekommen %v", err)
|
||||
}
|
||||
if _, err := NewEvent(EventError, make(chan int)); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Errorf("nicht serialisierbare Payload: erwartet ErrInvalidEvent, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEvent_UniqueIDs(t *testing.T) {
|
||||
seen := map[string]bool{}
|
||||
for range 1000 {
|
||||
ev, err := NewEvent(EventError, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if seen[ev.ID] {
|
||||
t.Fatalf("doppelte ID %q", ev.ID)
|
||||
}
|
||||
seen[ev.ID] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Feldnamen gemäß Spec Abschnitt 4.2.
|
||||
func TestEvent_JSONFieldNames(t *testing.T) {
|
||||
ev := Event{
|
||||
ID: "evt_1",
|
||||
Timestamp: 1727618531,
|
||||
Type: EventServiceState,
|
||||
NodeID: "node-proxmox-vm-42",
|
||||
Payload: json.RawMessage(`{"a":1}`),
|
||||
}
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := `{"event_id":"evt_1","timestamp":1727618531,"type":"service_state","node_id":"node-proxmox-vm-42","payload":{"a":1}}`
|
||||
if string(b) != want {
|
||||
t.Errorf("JSON weicht ab\n got: %s\nwant: %s", b, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNodeOffline: Für den Knoten ist kein Stream offen.
|
||||
ErrNodeOffline = errors.New("stream: Knoten nicht verbunden")
|
||||
// ErrBufferFull: Der Knoten liest nicht schnell genug; das Event wurde verworfen.
|
||||
ErrBufferFull = errors.New("stream: Sendepuffer voll")
|
||||
// ErrHubClosed: Der Hub wurde geschlossen.
|
||||
ErrHubClosed = errors.New("stream: Hub geschlossen")
|
||||
// ErrReplaced: Derselbe Knoten hat sich neu verbunden; der alte Stream endet.
|
||||
ErrReplaced = errors.New("stream: durch neue Verbindung ersetzt")
|
||||
// ErrStreamingUnsupported: Der ResponseWriter kann nicht flushen.
|
||||
ErrStreamingUnsupported = errors.New("stream: ResponseWriter unterstützt kein Streaming")
|
||||
// ErrInvalidNodeID: Serve wurde ohne Knoten-ID aufgerufen.
|
||||
ErrInvalidNodeID = errors.New("stream: Knoten-ID fehlt")
|
||||
// ErrDisconnected: Der Server hat den Stream per Disconnect beendet.
|
||||
ErrDisconnected = errors.New("stream: vom Server getrennt")
|
||||
)
|
||||
|
||||
// Hub hält die offenen Streams aller Knoten. Der Nullwert ist einsatzbereit.
|
||||
// Felder dürfen nach der ersten Verwendung nicht mehr geändert werden.
|
||||
type Hub struct {
|
||||
// KeepAlive ist das Intervall der Keepalive-Kommentare (Default 45s).
|
||||
KeepAlive time.Duration
|
||||
// WriteTimeout begrenzt jeden einzelnen Schreibvorgang (Default 10s).
|
||||
// Hängt ein Schreibvorgang länger, gilt der Knoten als tot.
|
||||
WriteTimeout time.Duration
|
||||
// BufferSize ist die Zahl der Events, die pro Knoten gepuffert werden (Default 16).
|
||||
BufferSize int
|
||||
// OnConnect und OnDisconnect werden aufgerufen, wenn ein Knoten online
|
||||
// bzw. offline geht. Ersetzt ein Knoten seine eigene Verbindung, gibt es
|
||||
// nur ein weiteres OnConnect, kein OnDisconnect.
|
||||
OnConnect func(nodeID string)
|
||||
OnDisconnect func(nodeID string)
|
||||
|
||||
mu sync.Mutex
|
||||
conns map[string]*conn
|
||||
closed bool
|
||||
}
|
||||
|
||||
type conn struct {
|
||||
frames chan []byte
|
||||
done chan struct{}
|
||||
once sync.Once
|
||||
reason error
|
||||
}
|
||||
|
||||
func (c *conn) stop(reason error) {
|
||||
c.once.Do(func() {
|
||||
c.reason = reason
|
||||
close(c.done)
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Hub) keepAlive() time.Duration { return orDefault(h.KeepAlive, 45*time.Second) }
|
||||
func (h *Hub) writeTimeout() time.Duration { return orDefault(h.WriteTimeout, 10*time.Second) }
|
||||
|
||||
func (h *Hub) bufferSize() int {
|
||||
if h.BufferSize > 0 {
|
||||
return h.BufferSize
|
||||
}
|
||||
return 16
|
||||
}
|
||||
|
||||
// Serve betreibt den Stream für nodeID auf w, bis der Client die Verbindung
|
||||
// trennt, ein Schreibvorgang fehlschlägt, derselbe Knoten sich neu verbindet
|
||||
// (ErrReplaced) oder der Hub geschlossen wird (ErrHubClosed). Authentifizierung
|
||||
// ist Sache des Aufrufers: nodeID muss bereits geprüft sein.
|
||||
func (h *Hub) Serve(w http.ResponseWriter, r *http.Request, nodeID string) error {
|
||||
if nodeID == "" {
|
||||
return ErrInvalidNodeID
|
||||
}
|
||||
h.mu.Lock()
|
||||
closed := h.closed
|
||||
h.mu.Unlock()
|
||||
if closed {
|
||||
return ErrHubClosed
|
||||
}
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no") // Reverse-Proxies nicht puffern lassen
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := rc.Flush(); err != nil {
|
||||
if errors.Is(err, http.ErrNotSupported) {
|
||||
return ErrStreamingUnsupported
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
c := &conn{frames: make(chan []byte, h.bufferSize()), done: make(chan struct{})}
|
||||
if err := h.register(nodeID, c); err != nil {
|
||||
return err
|
||||
}
|
||||
defer h.unregister(nodeID, c)
|
||||
|
||||
ticker := time.NewTicker(h.keepAlive())
|
||||
defer ticker.Stop()
|
||||
write := func(p []byte) error {
|
||||
err := rc.SetWriteDeadline(time.Now().Add(h.writeTimeout()))
|
||||
if err != nil && !errors.Is(err, http.ErrNotSupported) {
|
||||
return err
|
||||
}
|
||||
if _, err := w.Write(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return rc.Flush()
|
||||
}
|
||||
|
||||
keepalive := []byte(":\n\n")
|
||||
for {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
return r.Context().Err()
|
||||
case <-c.done:
|
||||
return c.reason
|
||||
case frame := <-c.frames:
|
||||
if err := write(frame); err != nil {
|
||||
return err
|
||||
}
|
||||
ticker.Reset(h.keepAlive())
|
||||
case <-ticker.C:
|
||||
if err := write(keepalive); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) register(nodeID string, c *conn) error {
|
||||
h.mu.Lock()
|
||||
if h.closed {
|
||||
h.mu.Unlock()
|
||||
return ErrHubClosed
|
||||
}
|
||||
if h.conns == nil {
|
||||
h.conns = map[string]*conn{}
|
||||
}
|
||||
if old := h.conns[nodeID]; old != nil {
|
||||
old.stop(ErrReplaced)
|
||||
}
|
||||
h.conns[nodeID] = c
|
||||
h.mu.Unlock()
|
||||
|
||||
if h.OnConnect != nil {
|
||||
h.OnConnect(nodeID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Hub) unregister(nodeID string, c *conn) {
|
||||
h.mu.Lock()
|
||||
current := h.conns[nodeID] == c
|
||||
if current {
|
||||
delete(h.conns, nodeID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
c.stop(ErrNodeOffline)
|
||||
if current && h.OnDisconnect != nil {
|
||||
h.OnDisconnect(nodeID)
|
||||
}
|
||||
}
|
||||
|
||||
// Send stellt ev in den Stream von nodeID, ohne zu blockieren. NodeID wird
|
||||
// immer auf nodeID gesetzt, ID und Timestamp nur ergänzt, wenn sie fehlen.
|
||||
func (h *Hub) Send(nodeID string, ev Event) error {
|
||||
if ev.Type == "" {
|
||||
return fmt.Errorf("%w: Typ fehlt", ErrInvalidEvent)
|
||||
}
|
||||
if ev.ID == "" {
|
||||
ev.ID = newEventID()
|
||||
}
|
||||
if ev.Timestamp == 0 {
|
||||
ev.Timestamp = time.Now().Unix()
|
||||
}
|
||||
ev.NodeID = nodeID
|
||||
frame, err := encode(ev)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
closed, c := h.closed, h.conns[nodeID]
|
||||
h.mu.Unlock()
|
||||
if closed {
|
||||
return ErrHubClosed
|
||||
}
|
||||
if c == nil {
|
||||
return ErrNodeOffline
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
return ErrNodeOffline
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case c.frames <- frame:
|
||||
return nil
|
||||
case <-c.done:
|
||||
return ErrNodeOffline
|
||||
default:
|
||||
return ErrBufferFull
|
||||
}
|
||||
}
|
||||
|
||||
// Disconnect beendet den Stream von nodeID sofort, z. B. wenn Mandant oder
|
||||
// Knoten gesperrt wurden. Der Knoten gilt ab Rückkehr als offline,
|
||||
// OnDisconnect wird aufgerufen, Serve endet mit ErrDisconnected.
|
||||
func (h *Hub) Disconnect(nodeID string) error {
|
||||
h.mu.Lock()
|
||||
c := h.conns[nodeID]
|
||||
if c != nil {
|
||||
delete(h.conns, nodeID)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if c == nil {
|
||||
return ErrNodeOffline
|
||||
}
|
||||
c.stop(ErrDisconnected)
|
||||
if h.OnDisconnect != nil {
|
||||
h.OnDisconnect(nodeID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Connected meldet, ob für nodeID ein Stream offen ist.
|
||||
func (h *Hub) Connected(nodeID string) bool {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return h.conns[nodeID] != nil
|
||||
}
|
||||
|
||||
// Nodes liefert die verbundenen Knoten, sortiert.
|
||||
func (h *Hub) Nodes() []string {
|
||||
h.mu.Lock()
|
||||
nodes := make([]string, 0, len(h.conns))
|
||||
for n := range h.conns {
|
||||
nodes = append(nodes, n)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
slices.Sort(nodes)
|
||||
return nodes
|
||||
}
|
||||
|
||||
// Close beendet alle Streams; danach liefern Serve und Send ErrHubClosed.
|
||||
func (h *Hub) Close() {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
h.closed = true
|
||||
for _, c := range h.conns {
|
||||
c.stop(ErrHubClosed)
|
||||
}
|
||||
}
|
||||
|
||||
func orDefault(d, def time.Duration) time.Duration {
|
||||
if d > 0 {
|
||||
return d
|
||||
}
|
||||
return def
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
package stream
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// eventually wartet, bis cond wahr ist, höchstens zwei Sekunden.
|
||||
func eventually(t *testing.T, msg string, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("Bedingung nicht erfüllt: %s", msg)
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// hubServer startet einen Testserver, der Verbindungen an h übergibt. Die
|
||||
// Knoten-ID kommt aus ?node=. Rückgabewerte von Serve landen in errs.
|
||||
func hubServer(t *testing.T, h *Hub) (srv *httptest.Server, errs chan error) {
|
||||
t.Helper()
|
||||
errs = make(chan error, 16)
|
||||
srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
errs <- h.Serve(w, r, r.URL.Query().Get("node"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
t.Cleanup(h.Close)
|
||||
return srv, errs
|
||||
}
|
||||
|
||||
func openStream(t *testing.T, ctx context.Context, srv *httptest.Server, node string) *http.Response {
|
||||
t.Helper()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"?node="+node, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := srv.Client().Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("Verbindung fehlgeschlagen: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { resp.Body.Close() })
|
||||
return resp
|
||||
}
|
||||
|
||||
func waitErr(t *testing.T, errs <-chan error) error {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-errs:
|
||||
return err
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Serve ist nicht zurückgekehrt")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SendOffline(t *testing.T) {
|
||||
var h Hub
|
||||
if err := h.Send("node-1", Event{Type: EventNodeCommand}); !errors.Is(err, ErrNodeOffline) {
|
||||
t.Errorf("erwartet ErrNodeOffline, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Defaults(t *testing.T) {
|
||||
var h Hub
|
||||
if got := h.keepAlive(); got != 45*time.Second {
|
||||
t.Errorf("KeepAlive-Default = %v, erwartet 45s (Spec)", got)
|
||||
}
|
||||
if got := h.writeTimeout(); got != 10*time.Second {
|
||||
t.Errorf("WriteTimeout-Default = %v", got)
|
||||
}
|
||||
if got := h.bufferSize(); got != 16 {
|
||||
t.Errorf("BufferSize-Default = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DeliversEvent(t *testing.T) {
|
||||
h := &Hub{}
|
||||
srv, _ := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "node-1")
|
||||
|
||||
if ct := resp.Header.Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Errorf("Content-Type = %q", ct)
|
||||
}
|
||||
if cc := resp.Header.Get("Cache-Control"); cc != "no-cache" {
|
||||
t.Errorf("Cache-Control = %q", cc)
|
||||
}
|
||||
eventually(t, "node-1 verbunden", func() bool { return h.Connected("node-1") })
|
||||
|
||||
if err := h.Send("node-1", Event{Type: EventNodeCommand, Payload: json.RawMessage(`{"action":"drain"}`)}); err != nil {
|
||||
t.Fatalf("Send: %v", err)
|
||||
}
|
||||
ev, err := NewDecoder(resp.Body).Next()
|
||||
if err != nil {
|
||||
t.Fatalf("Next: %v", err)
|
||||
}
|
||||
if ev.Type != EventNodeCommand || ev.NodeID != "node-1" || string(ev.Payload) != `{"action":"drain"}` {
|
||||
t.Errorf("unerwartetes Event: %+v", ev)
|
||||
}
|
||||
if !strings.HasPrefix(ev.ID, "evt_") || ev.Timestamp == 0 {
|
||||
t.Errorf("ID/Timestamp nicht ergänzt: %+v", ev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SendKeepsIDAndTimestamp(t *testing.T) {
|
||||
h := &Hub{}
|
||||
srv, _ := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "n")
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("n") })
|
||||
|
||||
want := Event{ID: "evt_fix", Timestamp: 42, Type: EventLeaseRenew, NodeID: "n"}
|
||||
if err := h.Send("n", Event{ID: "evt_fix", Timestamp: 42, Type: EventLeaseRenew, NodeID: "anderer"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := NewDecoder(resp.Body).Next()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("got %+v, erwartet %+v (NodeID wird immer vom Hub gesetzt)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_SendInvalid(t *testing.T) {
|
||||
h := &Hub{}
|
||||
srv, _ := hubServer(t, h)
|
||||
openStream(t, context.Background(), srv, "n")
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("n") })
|
||||
|
||||
if err := h.Send("n", Event{}); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Errorf("leerer Typ: erwartet ErrInvalidEvent, bekommen %v", err)
|
||||
}
|
||||
if err := h.Send("n", Event{Type: EventError, Payload: json.RawMessage(`{`)}); !errors.Is(err, ErrInvalidEvent) {
|
||||
t.Errorf("kaputte Payload: erwartet ErrInvalidEvent, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_KeepAlive(t *testing.T) {
|
||||
h := &Hub{KeepAlive: 10 * time.Millisecond}
|
||||
srv, _ := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "n")
|
||||
|
||||
buf := make([]byte, 6)
|
||||
if _, err := io.ReadFull(resp.Body, buf); err != nil {
|
||||
t.Fatalf("lesen: %v", err)
|
||||
}
|
||||
if string(buf) != ":\n\n:\n\n" {
|
||||
t.Errorf("erwartet zwei Keepalives \":\\n\\n\", bekommen %q", buf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_DisconnectDetected(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var connects, disconnects []string
|
||||
h := &Hub{
|
||||
OnConnect: func(n string) { mu.Lock(); connects = append(connects, n); mu.Unlock() },
|
||||
OnDisconnect: func(n string) { mu.Lock(); disconnects = append(disconnects, n); mu.Unlock() },
|
||||
}
|
||||
srv, errs := hubServer(t, h)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
openStream(t, ctx, srv, "node-1")
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("node-1") })
|
||||
|
||||
cancel()
|
||||
if err := waitErr(t, errs); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Serve: erwartet context.Canceled, bekommen %v", err)
|
||||
}
|
||||
if h.Connected("node-1") {
|
||||
t.Error("node-1 noch verbunden")
|
||||
}
|
||||
if err := h.Send("node-1", Event{Type: EventError}); !errors.Is(err, ErrNodeOffline) {
|
||||
t.Errorf("erwartet ErrNodeOffline, bekommen %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !reflect.DeepEqual(connects, []string{"node-1"}) || !reflect.DeepEqual(disconnects, []string{"node-1"}) {
|
||||
t.Errorf("Callbacks: connects=%v disconnects=%v", connects, disconnects)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ReconnectReplacesOld(t *testing.T) {
|
||||
var connects, disconnects atomic.Int32
|
||||
h := &Hub{
|
||||
OnConnect: func(string) { connects.Add(1) },
|
||||
OnDisconnect: func(string) { disconnects.Add(1) },
|
||||
}
|
||||
srv, errs := hubServer(t, h)
|
||||
old := openStream(t, context.Background(), srv, "n")
|
||||
eventually(t, "erste Verbindung", func() bool { return connects.Load() == 1 })
|
||||
fresh := openStream(t, context.Background(), srv, "n")
|
||||
|
||||
if err := waitErr(t, errs); !errors.Is(err, ErrReplaced) {
|
||||
t.Errorf("alte Verbindung: erwartet ErrReplaced, bekommen %v", err)
|
||||
}
|
||||
if _, err := io.ReadAll(old.Body); err != nil {
|
||||
t.Errorf("alter Stream sollte sauber enden: %v", err)
|
||||
}
|
||||
if err := h.Send("n", Event{Type: EventLeaseRenew}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ev, err := NewDecoder(fresh.Body).Next()
|
||||
if err != nil || ev.Type != EventLeaseRenew {
|
||||
t.Errorf("neue Verbindung: ev=%+v err=%v", ev, err)
|
||||
}
|
||||
if c, d := connects.Load(), disconnects.Load(); c != 2 || d != 0 {
|
||||
t.Errorf("Callbacks: connects=%d disconnects=%d, erwartet 2/0 (Ersatz ist kein Offline)", c, d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Nodes(t *testing.T) {
|
||||
h := &Hub{}
|
||||
srv, _ := hubServer(t, h)
|
||||
openStream(t, context.Background(), srv, "b")
|
||||
openStream(t, context.Background(), srv, "a")
|
||||
eventually(t, "beide verbunden", func() bool { return len(h.Nodes()) == 2 })
|
||||
if got := h.Nodes(); !reflect.DeepEqual(got, []string{"a", "b"}) {
|
||||
t.Errorf("Nodes = %v, erwartet sortiert [a b]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Close(t *testing.T) {
|
||||
h := &Hub{}
|
||||
srv, errs := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "n")
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("n") })
|
||||
|
||||
h.Close()
|
||||
if err := waitErr(t, errs); !errors.Is(err, ErrHubClosed) {
|
||||
t.Errorf("Serve: erwartet ErrHubClosed, bekommen %v", err)
|
||||
}
|
||||
if _, err := io.ReadAll(resp.Body); err != nil {
|
||||
t.Errorf("Stream sollte sauber enden: %v", err)
|
||||
}
|
||||
if err := h.Send("n", Event{Type: EventError}); !errors.Is(err, ErrHubClosed) {
|
||||
t.Errorf("Send nach Close: erwartet ErrHubClosed, bekommen %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
if err := h.Serve(httptest.NewRecorder(), req, "neu"); !errors.Is(err, ErrHubClosed) {
|
||||
t.Errorf("Serve nach Close: erwartet ErrHubClosed, bekommen %v", err)
|
||||
}
|
||||
h.Close() // zweimal schließen darf nicht paniken
|
||||
}
|
||||
|
||||
// blockingWriter simuliert einen Client, der nicht mehr liest.
|
||||
type blockingWriter struct {
|
||||
header http.Header
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (w *blockingWriter) Header() http.Header { return w.header }
|
||||
func (w *blockingWriter) WriteHeader(int) {}
|
||||
func (w *blockingWriter) Flush() {}
|
||||
func (w *blockingWriter) Write(p []byte) (int, error) { <-w.release; return len(p), nil }
|
||||
|
||||
func TestHub_BufferFull(t *testing.T) {
|
||||
h := &Hub{BufferSize: 2}
|
||||
w := &blockingWriter{header: http.Header{}, release: make(chan struct{})}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan error, 1)
|
||||
go func() { done <- h.Serve(w, httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx), "n") }()
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("n") })
|
||||
|
||||
var full bool
|
||||
for range 4 {
|
||||
if err := h.Send("n", Event{Type: EventError}); errors.Is(err, ErrBufferFull) {
|
||||
full = true
|
||||
}
|
||||
}
|
||||
if !full {
|
||||
t.Error("erwartet ErrBufferFull bei vollem Puffer")
|
||||
}
|
||||
cancel()
|
||||
close(w.release)
|
||||
if err := waitErr(t, done); !errors.Is(err, context.Canceled) {
|
||||
t.Errorf("Serve: erwartet context.Canceled, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{ header http.Header }
|
||||
|
||||
var errWrite = errors.New("Verbindung weg")
|
||||
|
||||
func (w *failingWriter) Header() http.Header { return w.header }
|
||||
func (w *failingWriter) WriteHeader(int) {}
|
||||
func (w *failingWriter) Flush() {}
|
||||
func (w *failingWriter) Write([]byte) (int, error) { return 0, errWrite }
|
||||
|
||||
func TestHub_WriteErrorEndsServe(t *testing.T) {
|
||||
h := &Hub{KeepAlive: 5 * time.Millisecond}
|
||||
err := h.Serve(&failingWriter{header: http.Header{}}, httptest.NewRequest(http.MethodGet, "/", nil), "n")
|
||||
if !errors.Is(err, errWrite) {
|
||||
t.Errorf("erwartet Schreibfehler, bekommen %v", err)
|
||||
}
|
||||
if h.Connected("n") {
|
||||
t.Error("Knoten nach Schreibfehler noch verbunden")
|
||||
}
|
||||
}
|
||||
|
||||
type noFlushWriter struct{ header http.Header }
|
||||
|
||||
func (w *noFlushWriter) Header() http.Header { return w.header }
|
||||
func (w *noFlushWriter) WriteHeader(int) {}
|
||||
func (w *noFlushWriter) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
func TestHub_StreamingUnsupported(t *testing.T) {
|
||||
var h Hub
|
||||
err := h.Serve(&noFlushWriter{header: http.Header{}}, httptest.NewRequest(http.MethodGet, "/", nil), "n")
|
||||
if !errors.Is(err, ErrStreamingUnsupported) {
|
||||
t.Errorf("erwartet ErrStreamingUnsupported, bekommen %v", err)
|
||||
}
|
||||
if h.Connected("n") {
|
||||
t.Error("Knoten darf nicht registriert sein")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_ConcurrentSend(t *testing.T) {
|
||||
const senders, perSender = 20, 50
|
||||
h := &Hub{}
|
||||
srv, _ := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "n")
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("n") })
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for range senders {
|
||||
wg.Go(func() {
|
||||
for range perSender {
|
||||
for {
|
||||
err := h.Send("n", Event{Type: EventServiceState})
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !errors.Is(err, ErrBufferFull) {
|
||||
t.Errorf("Send: %v", err)
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
dec := NewDecoder(resp.Body)
|
||||
for i := range senders * perSender {
|
||||
if _, err := dec.Next(); err != nil {
|
||||
t.Fatalf("Event %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestHub_ServeRejectsEmptyNodeID(t *testing.T) {
|
||||
var connects atomic.Int32
|
||||
h := &Hub{OnConnect: func(string) { connects.Add(1) }}
|
||||
rec := httptest.NewRecorder()
|
||||
err := h.Serve(rec, httptest.NewRequest(http.MethodGet, "/", nil), "")
|
||||
if !errors.Is(err, ErrInvalidNodeID) {
|
||||
t.Errorf("erwartet ErrInvalidNodeID, bekommen %v", err)
|
||||
}
|
||||
if rec.Body.Len() != 0 || rec.Header().Get("Content-Type") == "text/event-stream" {
|
||||
t.Error("bei ungültiger ID darf kein Stream begonnen werden")
|
||||
}
|
||||
if connects.Load() != 0 || len(h.Nodes()) != 0 {
|
||||
t.Error("leere ID wurde registriert")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHub_Disconnect(t *testing.T) {
|
||||
var disconnects atomic.Int32
|
||||
h := &Hub{OnDisconnect: func(string) { disconnects.Add(1) }}
|
||||
srv, errs := hubServer(t, h)
|
||||
resp := openStream(t, context.Background(), srv, "n")
|
||||
eventually(t, "verbunden", func() bool { return h.Connected("n") })
|
||||
|
||||
if err := h.Disconnect("n"); err != nil {
|
||||
t.Fatalf("Disconnect: %v", err)
|
||||
}
|
||||
// Sofort wirksam, nicht erst wenn Serve zurückkehrt:
|
||||
if h.Connected("n") {
|
||||
t.Error("Knoten nach Disconnect noch verbunden")
|
||||
}
|
||||
if err := h.Send("n", Event{Type: EventError}); !errors.Is(err, ErrNodeOffline) {
|
||||
t.Errorf("Send nach Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
|
||||
}
|
||||
if err := waitErr(t, errs); !errors.Is(err, ErrDisconnected) {
|
||||
t.Errorf("Serve: erwartet ErrDisconnected, bekommen %v", err)
|
||||
}
|
||||
if _, err := io.ReadAll(resp.Body); err != nil {
|
||||
t.Errorf("Stream sollte sauber enden: %v", err)
|
||||
}
|
||||
if n := disconnects.Load(); n != 1 {
|
||||
t.Errorf("OnDisconnect %d-mal aufgerufen, erwartet 1", n)
|
||||
}
|
||||
if err := h.Disconnect("n"); !errors.Is(err, ErrNodeOffline) {
|
||||
t.Errorf("zweites Disconnect: erwartet ErrNodeOffline, bekommen %v", err)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user