client.go 5.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159
  1. // Package wsclient is a thin wrapper around gorilla/websocket
  2. // for the M5 WebSocket ingest path. Used by:
  3. //
  4. // - loadgen/cmd/ws (the loadgen-ws publisher)
  5. // - scripts/m5_smoke.sh test programs (the failure-path
  6. // binaries)
  7. //
  8. // The wrapper hides the URL/dial, the optional Origin, and the
  9. // read/write deadlines. It does NOT hide the JSON envelope
  10. // shape — callers marshal their own alert bodies and parse the
  11. // server's ack frames.
  12. package wsclient
  13. import (
  14. "errors"
  15. "fmt"
  16. "net/url"
  17. "time"
  18. "github.com/gorilla/websocket"
  19. )
  20. // Config is the dial parameters.
  21. type Config struct {
  22. // URL is the WebSocket endpoint, e.g. ws://localhost:8800/v1/ingest/ws
  23. URL string
  24. // APIKey is the source API key (the same
  25. // `acme-001:prom-prod:s3cret-acme` triple the HTTP and MQTT
  26. // loadgens use). The first frame sent to the server is
  27. // `{"api_key": "..."}`. The server replies with either
  28. // `{"ready": true}` or `{"error": "..."}`. The Connect
  29. // helper stashes that first reply on the client so callers
  30. // can distinguish "auth accepted" from "auth rejected"
  31. // without sending a test alert.
  32. APIKey string
  33. Origin string
  34. DialTimeout time.Duration
  35. WriteTimeout time.Duration
  36. ReadTimeout time.Duration
  37. }
  38. // Client is the live WS connection. The auth reply is kept so
  39. // callers can surface "tail: unauthorized" instead of just
  40. // "tail: dial ok".
  41. type Client struct {
  42. cfg Config
  43. conn *websocket.Conn
  44. authReply []byte
  45. }
  46. // Connect dials the WS endpoint, sends the auth frame, and
  47. // returns a ready-to-use client. The auth frame is the only
  48. // non-alert frame; subsequent SendAlert calls are pure alert
  49. // frames.
  50. func Connect(cfg Config) (*Client, error) {
  51. if cfg.URL == "" {
  52. return nil, errors.New("wsclient: empty URL")
  53. }
  54. if cfg.APIKey == "" {
  55. return nil, errors.New("wsclient: empty APIKey")
  56. }
  57. if cfg.DialTimeout == 0 {
  58. cfg.DialTimeout = 10 * time.Second
  59. }
  60. if cfg.WriteTimeout == 0 {
  61. cfg.WriteTimeout = 30 * time.Second
  62. }
  63. if cfg.ReadTimeout == 0 {
  64. cfg.ReadTimeout = 30 * time.Second
  65. }
  66. if _, err := url.Parse(cfg.URL); err != nil {
  67. return nil, fmt.Errorf("wsclient: bad URL %q: %w", cfg.URL, err)
  68. }
  69. dialer := *websocket.DefaultDialer
  70. dialer.HandshakeTimeout = cfg.DialTimeout
  71. headers := map[string][]string{}
  72. if cfg.Origin != "" {
  73. headers["Origin"] = []string{cfg.Origin}
  74. }
  75. conn, _, err := dialer.Dial(cfg.URL, headers)
  76. if err != nil {
  77. return nil, fmt.Errorf("wsclient: dial: %w", err)
  78. }
  79. // Auth frame
  80. authFrame := []byte(fmt.Sprintf(`{"api_key":%q}`, cfg.APIKey))
  81. _ = conn.SetWriteDeadline(time.Now().Add(cfg.WriteTimeout))
  82. if err := conn.WriteMessage(websocket.TextMessage, authFrame); err != nil {
  83. _ = conn.Close()
  84. return nil, fmt.Errorf("wsclient: write auth: %w", err)
  85. }
  86. // Wait for the server's auth reply
  87. _ = conn.SetReadDeadline(time.Now().Add(cfg.ReadTimeout))
  88. _, msg, err := conn.ReadMessage()
  89. if err != nil {
  90. _ = conn.Close()
  91. return nil, fmt.Errorf("wsclient: read auth reply: %w", err)
  92. }
  93. if len(msg) == 0 {
  94. _ = conn.Close()
  95. return nil, errors.New("wsclient: empty auth reply")
  96. }
  97. return &Client{cfg: cfg, conn: conn, authReply: msg}, nil
  98. }
  99. // AuthReply returns the server's first frame. The caller can
  100. // parse it ({"ready": true} or {"error": "..."}) to surface a
  101. // clear error.
  102. func (c *Client) AuthReply() []byte { return c.authReply }
  103. // SendAlert writes one alert frame and reads one ack frame.
  104. // Both operations use the per-frame timeouts from Config. The
  105. // ack is the server's reply for THIS alert; if the server is
  106. // publishing a tail event mid-pipeline it will NOT interleave
  107. // here (the tail is a separate connection).
  108. func (c *Client) SendAlert(body []byte) ([]byte, error) {
  109. _ = c.conn.SetWriteDeadline(time.Now().Add(c.cfg.WriteTimeout))
  110. if err := c.conn.WriteMessage(websocket.TextMessage, body); err != nil {
  111. return nil, fmt.Errorf("wsclient: write alert: %w", err)
  112. }
  113. _ = c.conn.SetReadDeadline(time.Now().Add(c.cfg.ReadTimeout))
  114. _, ack, err := c.conn.ReadMessage()
  115. if err != nil {
  116. return nil, fmt.Errorf("wsclient: read ack: %w", err)
  117. }
  118. return ack, nil
  119. }
  120. // Close sends a graceful close frame and then closes the
  121. // underlying TCP connection. Safe to call multiple times.
  122. //
  123. // We send CloseMessage with code 1000 (normal closure) and an
  124. // empty payload. The server's ReadMessage then returns a
  125. // CloseError with code 1000, which the WS ingest path
  126. // classifies as "closed_clean" instead of
  127. // "closed_protocol_error".
  128. func (c *Client) Close() error {
  129. if c.conn == nil {
  130. return nil
  131. }
  132. deadline := time.Now().Add(2 * time.Second)
  133. _ = c.conn.WriteControl(
  134. websocket.CloseMessage,
  135. websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
  136. deadline,
  137. )
  138. err := c.conn.Close()
  139. c.conn = nil
  140. return err
  141. }
  142. // Conn exposes the underlying gorilla connection. Used by the
  143. // tail client (which doesn't have a request/response model —
  144. // it just reads frames). Wrap with a custom reader if you
  145. // don't want callers reaching in.
  146. func (c *Client) Conn() *websocket.Conn { return c.conn }