| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315 |
- package nebula
- import (
- "bytes"
- "context"
- "errors"
- "fmt"
- "io"
- "net"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/rcrowley/go-metrics"
- "github.com/slackhq/nebula/test"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- )
- // graphiteHost accepts connections and hands each one to the test
- type graphiteHost struct {
- ln net.Listener
- conns chan net.Conn
- mu sync.Mutex
- closed bool
- open []net.Conn
- }
- // newGraphiteHost accepts connections for the test. A non-zero window shrinks each connection's receive buffer, so a
- // sender that is not read from blocks after a little whatever the host's TCP tuning. Only for tests that need a stall,
- // a small window makes reading very slow on Linux.
- func newGraphiteHost(t *testing.T, window int) *graphiteHost {
- t.Helper()
- ln, err := net.Listen("tcp", "127.0.0.1:0")
- require.NoError(t, err)
- h := &graphiteHost{ln: ln, conns: make(chan net.Conn, 8)}
- done := make(chan struct{})
- t.Cleanup(func() {
- _ = ln.Close()
- h.mu.Lock()
- h.closed = true
- for _, c := range h.open {
- _ = c.Close()
- }
- h.mu.Unlock()
- close(done)
- })
- go func() {
- for {
- c, err := ln.Accept()
- if err != nil {
- return
- }
- h.mu.Lock()
- if h.closed {
- h.mu.Unlock()
- _ = c.Close()
- return
- }
- h.open = append(h.open, c)
- h.mu.Unlock()
- if tc, ok := c.(*net.TCPConn); ok && window > 0 {
- _ = tc.SetReadBuffer(window)
- }
- select {
- case h.conns <- c:
- case <-done:
- return
- }
- }
- }()
- return h
- }
- func (h *graphiteHost) addr(t *testing.T) *net.TCPAddr {
- a, err := net.ResolveTCPAddr("tcp", h.ln.Addr().String())
- require.NoError(t, err)
- return a
- }
- // readExport reads what one send wrote, bounded so a send that never finishes fails the test rather than hanging it
- func readExport(t *testing.T, c net.Conn) []byte {
- t.Helper()
- require.NoError(t, c.SetReadDeadline(time.Now().Add(10*time.Second)))
- b, err := io.ReadAll(c)
- require.NoError(t, err)
- return b
- }
- func (h *graphiteHost) next(t *testing.T, wait time.Duration) net.Conn {
- t.Helper()
- select {
- case c := <-h.conns:
- return c
- case <-time.After(wait):
- t.Fatal("no connection")
- return nil
- }
- }
- // lockedBuffer is a log destination a test can read while the code under test writes to it from another goroutine
- type lockedBuffer struct {
- mu sync.Mutex
- buf bytes.Buffer
- }
- func (b *lockedBuffer) Write(p []byte) (int, error) {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.buf.Write(p)
- }
- func (b *lockedBuffer) String() string {
- b.mu.Lock()
- defer b.mu.Unlock()
- return b.buf.String()
- }
- func testGraphiteConfig(addr *net.TCPAddr, r metrics.Registry) graphiteConfigExport {
- return graphiteConfigExport{Addr: addr, Registry: r, FlushInterval: time.Second, DurationUnit: time.Nanosecond, Prefix: "nebula"}
- }
- // Enough metrics that one export overflows the socket buffers on any platform, so a send to a host that stops
- // reading blocks
- func bigGraphiteRegistry(r metrics.Registry, t *testing.T) {
- for i := range 200000 {
- name := fmt.Sprintf("big.gauge.%d", i)
- metrics.GetOrRegisterGauge(name, r).Update(int64(i))
- t.Cleanup(func() { r.Unregister(name) })
- }
- }
- func TestGraphiteOnce_sends(t *testing.T) {
- r := metrics.NewRegistry()
- metrics.GetOrRegisterGauge("g", r).Update(7)
- metrics.GetOrRegisterCounter("c", r).Inc(3)
- h := newGraphiteHost(t, 0)
- require.NoError(t, graphiteOnce(t.Context(), testGraphiteConfig(h.addr(t), r), time.Minute))
- b := readExport(t, h.next(t, 5*time.Second))
- assert.Contains(t, string(b), "nebula.g.value 7 ")
- assert.Contains(t, string(b), "nebula.c.count 3 ")
- assert.Contains(t, string(b), "nebula.c.count_ps 3.00 ")
- }
- // A host that accepts and never reads is given up on, and the failed write is reported
- func TestGraphiteOnce_stalledHostTimesOut(t *testing.T) {
- r := metrics.NewRegistry()
- bigGraphiteRegistry(r, t)
- h := newGraphiteHost(t, 4096)
- done := make(chan error, 1)
- cfg := testGraphiteConfig(h.addr(t), r)
- go func() { done <- graphiteOnce(t.Context(), cfg, 300*time.Millisecond) }()
- h.next(t, 5*time.Second) // accepted, never read
- select {
- case err := <-done:
- require.Error(t, err)
- case <-time.After(10 * time.Second):
- t.Fatal("the send did not give up on a host that stopped reading")
- }
- }
- // The timeout covers the whole send, a host that keeps reading but too slowly is still given up on
- func TestGraphiteOnce_timeoutCoversTheWholeSend(t *testing.T) {
- r := metrics.NewRegistry()
- bigGraphiteRegistry(r, t)
- h := newGraphiteHost(t, 0)
- done := make(chan error, 1)
- start := time.Now()
- cfg := testGraphiteConfig(h.addr(t), r)
- go func() { done <- graphiteOnce(t.Context(), cfg, 500*time.Millisecond) }()
- c := h.next(t, 5*time.Second)
- stopReading := make(chan struct{})
- defer close(stopReading)
- // 32KB every 10ms keeps every write well inside the timeout, the whole export takes seconds
- go func() {
- buf := make([]byte, 32<<10)
- for {
- select {
- case <-stopReading:
- return
- case <-time.After(10 * time.Millisecond):
- }
- if _, err := c.Read(buf); err != nil {
- return
- }
- }
- }()
- select {
- case err := <-done:
- require.Error(t, err)
- assert.Less(t, time.Since(start), 5*time.Second)
- case <-time.After(10 * time.Second):
- t.Fatal("a host that kept reading slowly held the send past its timeout")
- }
- }
- func TestGraphiteOnce_cancelledCtxDoesNotConnect(t *testing.T) {
- h := newGraphiteHost(t, 0)
- ctx, cancel := context.WithCancel(t.Context())
- cancel()
- require.Error(t, graphiteOnce(ctx, testGraphiteConfig(h.addr(t), metrics.NewRegistry()), time.Minute))
- select {
- case <-h.conns:
- t.Fatal("connected on a cancelled ctx")
- case <-time.After(200 * time.Millisecond):
- }
- }
- // A stop or reload abandons a stuck send rather than waiting out its timeout
- func TestGraphiteOnce_cancelAbandonsSend(t *testing.T) {
- r := metrics.NewRegistry()
- bigGraphiteRegistry(r, t)
- h := newGraphiteHost(t, 4096)
- ctx, cancel := context.WithCancel(t.Context())
- done := make(chan error, 1)
- cfg := testGraphiteConfig(h.addr(t), r)
- go func() { done <- graphiteOnce(ctx, cfg, time.Minute) }()
- h.next(t, 5*time.Second)
- select {
- case err := <-done:
- t.Fatalf("the send should be stuck on a host that is not reading, it ended with %v", err)
- case <-time.After(200 * time.Millisecond):
- }
- cancel()
- select {
- case err := <-done:
- require.Error(t, err)
- case <-time.After(5 * time.Second):
- t.Fatal("the send is still stuck after its ctx was cancelled")
- }
- }
- // buildRuntime hands graphite the configured prefix and interval
- func TestStatsServer_graphiteExportUsesConfig(t *testing.T) {
- const name = "graphite.wiring.test"
- metrics.GetOrRegisterCounter(name, nil).Inc(8)
- t.Cleanup(func() { metrics.DefaultRegistry.Unregister(name) })
- h := newGraphiteHost(t, 0)
- s, c := newTestStatsServer(t)
- setStatsConfig(c, map[string]any{"type": "graphite", "interval": "4s", "prefix": "pfx", "host": h.ln.Addr().String()})
- cfg, err := loadStatsConfig(c)
- require.NoError(t, err)
- s.runMu.Lock()
- fns, _ := s.buildRuntime(t.Context(), cfg)
- s.runMu.Unlock()
- for _, fn := range fns {
- fn()
- }
- b := readExport(t, h.next(t, 5*time.Second))
- assert.Contains(t, string(b), "pfx."+name+".count 8 ")
- assert.Contains(t, string(b), "pfx."+name+".count_ps 2.00 ")
- }
- func TestStatsServer_graphiteFailureIsLogged(t *testing.T) {
- logs := &lockedBuffer{}
- s, c := newTestStatsServer(t)
- s.l = test.NewLoggerWithOutput(logs)
- setStatsConfig(c, map[string]any{"type": "graphite", "interval": "1h", "host": "127.0.0.1:" + freeTCPPort(t)})
- cfg, err := loadStatsConfig(c)
- require.NoError(t, err)
- s.runMu.Lock()
- fns, _ := s.buildRuntime(t.Context(), cfg)
- s.runMu.Unlock()
- for _, fn := range fns {
- fn()
- }
- assert.Contains(t, logs.String(), "Graphite export failed")
- }
- // Stop abandons the running export, so it goes through Start rather than handing buildRuntime a ctx, and a stop is
- // not logged as a failure
- func TestStatsServer_stopAbandonsGraphiteSend(t *testing.T) {
- bigGraphiteRegistry(metrics.DefaultRegistry, t)
- h := newGraphiteHost(t, 4096)
- logs := &lockedBuffer{}
- s, c := newTestStatsServer(t)
- s.l = test.NewLoggerWithOutput(logs)
- setStatsConfig(c, map[string]any{"type": "graphite", "interval": "50ms", "host": h.ln.Addr().String()})
- require.NoError(t, s.reload(c, true))
- started := make(chan struct{})
- go func() {
- s.Start()
- close(started)
- }()
- stuck := h.next(t, 30*time.Second)
- s.Stop()
- select {
- case <-started:
- case <-time.After(5 * time.Second):
- t.Fatal("Start did not return after Stop")
- }
- // A closed sender resets the connection once it sees this, one still sending just buffers it. Either way the
- // read below doesn't have to drain the export through the small window to find out
- _, err := stuck.Write([]byte("x"))
- require.NoError(t, err)
- require.NoError(t, stuck.SetReadDeadline(time.Now().Add(10*time.Second)))
- n, err := io.Copy(io.Discard, stuck)
- var ne net.Error
- if errors.As(err, &ne) && ne.Timeout() {
- t.Fatalf("the send kept the connection after Stop, read %d bytes", n)
- }
- assert.Less(t, n, int64(1<<20), "the send kept going after Stop")
- // The capture loop logs on its own goroutine after Start has returned, give it the chance
- assert.Never(t, func() bool { return strings.Contains(logs.String(), "Graphite export failed") }, 500*time.Millisecond, 10*time.Millisecond, "a stop is not a failure")
- }
|