main.go 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. // loadgen/cmd/ws is the M5 WebSocket publisher for
  2. // broad-announce. Same data shape as loadgen/cmd/http and
  3. // loadgen/cmd/mqtt, but talks the WS transport at
  4. // ws://ingestd:8800/v1/ingest/ws. The auth model is the same
  5. // as the HTTP path: the api-key is `company:source:secret`,
  6. // sent as a JSON frame on connect. The per-message HMAC is the
  7. // same X-BA-Signature as the HTTP path, embedded in the
  8. // envelope's `auth` field.
  9. //
  10. // Example:
  11. //
  12. // loadgen-ws --target ws://localhost:8800/v1/ingest/ws \
  13. // --api-key acme-001:prom-prod:s3cret-acme \
  14. // --count 10 --rate 5
  15. package main
  16. import (
  17. "context"
  18. "crypto/hmac"
  19. "crypto/sha256"
  20. "encoding/hex"
  21. "encoding/json"
  22. "flag"
  23. "fmt"
  24. "log/slog"
  25. "math/rand/v2"
  26. "os"
  27. "os/signal"
  28. "strings"
  29. "sync"
  30. "sync/atomic"
  31. "syscall"
  32. "time"
  33. "git3.techno-world.net/lrosales/broad-announce/internal/wsclient"
  34. )
  35. func main() {
  36. var (
  37. target = flag.String("target", "ws://localhost:8800/v1/ingest/ws", "WebSocket endpoint URL")
  38. apiKey = flag.String("api-key", "", "company:source:secret")
  39. count = flag.Int("count", 10, "total alerts to send")
  40. rate = flag.Int("rate", 10, "target alerts/sec")
  41. mode = flag.String("mode", "normal", "profile: normal|burst")
  42. dedupePct = flag.Int("dedupe-pct", 30, "percent sharing a dedupe_key (normal)")
  43. timeout = flag.Duration("duration", 30*time.Second, "max run time")
  44. concurrencyFlag = flag.Int("concurrency", 1, "parallel WS connections (each one is one source)")
  45. )
  46. flag.Parse()
  47. if *apiKey == "" {
  48. fmt.Fprintln(os.Stderr, "loadgen-ws: --api-key is required (company:source:secret)")
  49. os.Exit(2)
  50. }
  51. parts := strings.SplitN(*apiKey, ":", 3)
  52. if len(parts) != 3 {
  53. fmt.Fprintln(os.Stderr, "loadgen-ws: --api-key must be company:source:secret")
  54. os.Exit(2)
  55. }
  56. company, source, secret := parts[0], parts[1], parts[2]
  57. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  58. _, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  59. defer stop()
  60. logger.Info("publishing",
  61. "target", *target,
  62. "count", *count, "rate", *rate, "mode", *mode,
  63. "concurrency", *concurrencyFlag,
  64. )
  65. var (
  66. sent atomic.Uint64
  67. failed atomic.Uint64
  68. dupes atomic.Uint64
  69. idxCh = make(chan int, *count)
  70. )
  71. for i := 0; i < *count; i++ {
  72. idxCh <- i
  73. }
  74. close(idxCh)
  75. limiter := time.NewTicker(time.Second / time.Duration(*rate))
  76. defer limiter.Stop()
  77. deadline := time.Now().Add(*timeout)
  78. var wg sync.WaitGroup
  79. for w := 0; w < *concurrencyFlag; w++ {
  80. wg.Add(1)
  81. go func(workerID int) {
  82. defer wg.Done()
  83. c, err := wsclient.Connect(wsclient.Config{
  84. URL: *target,
  85. APIKey: *apiKey,
  86. })
  87. if err != nil {
  88. logger.Error("ws connect", "worker", workerID, "err", err)
  89. failed.Add(1)
  90. return
  91. }
  92. defer c.Close()
  93. logger.Info("ws connected", "worker", workerID, "auth_reply", string(c.AuthReply()))
  94. for i := range idxCh {
  95. if time.Now().After(deadline) {
  96. return
  97. }
  98. <-limiter.C
  99. a := makeAlert(i, *mode, *dedupePct, company, source)
  100. body, _ := json.Marshal(a)
  101. ts := time.Now().Unix()
  102. mac := hmac.New(sha256.New, []byte(secret))
  103. mac.Write([]byte(fmt.Sprintf("%d", ts)))
  104. mac.Write([]byte("."))
  105. mac.Write(body)
  106. sig := hex.EncodeToString(mac.Sum(nil))
  107. env := map[string]json.RawMessage{
  108. "alert": body,
  109. }
  110. env["auth"] = json.RawMessage(fmt.Sprintf("%q", fmt.Sprintf("t=%d,v1=%s", ts, sig)))
  111. envelope, _ := json.Marshal(env)
  112. ack, err := c.SendAlert(envelope)
  113. if err != nil {
  114. failed.Add(1)
  115. logger.Warn("ws send", "err", err, "i", i)
  116. continue
  117. }
  118. if !isUnique(*dedupePct, i) {
  119. dupes.Add(1)
  120. }
  121. sent.Add(1)
  122. if i == 0 || (i+1)%(*count/10+1) == 0 {
  123. logger.Info("progress", "worker", workerID, "sent", i+1, "total", *count, "ack", string(ack))
  124. }
  125. }
  126. }(w)
  127. }
  128. wg.Wait()
  129. logger.Info("done",
  130. "sent", sent.Load(),
  131. "failed", failed.Load(),
  132. "dupes", dupes.Load(),
  133. )
  134. if failed.Load() > 0 {
  135. os.Exit(1)
  136. }
  137. }
  138. // makeAlert is the same shape as loadgen/cmd/http and
  139. // loadgen/cmd/mqtt.
  140. func makeAlert(idx int, mode string, dedupePct int, company, source string) map[string]any {
  141. severity := pickSeverity(mode)
  142. dedupeKey := fmt.Sprintf("lg-m5-%d", idx)
  143. if dedupePct > 0 && idx > 0 && rand.IntN(100) < dedupePct {
  144. dedupeKey = "lg-m5-shared"
  145. }
  146. return map[string]any{
  147. "company_id": company,
  148. "source_id": source,
  149. "severity": severity,
  150. "category": "loadgen",
  151. "title": fmt.Sprintf("LG M5 #%d", idx),
  152. "body": "ws smoke",
  153. "data": map[string]string{"host": "lg-host", "idx": fmt.Sprintf("%d", idx)},
  154. "dedupe_key": dedupeKey,
  155. }
  156. }
  157. func pickSeverity(mode string) string {
  158. r := rand.IntN(100)
  159. switch mode {
  160. case "burst":
  161. switch {
  162. case r < 70:
  163. return "critical"
  164. case r < 95:
  165. return "inminent_colapse"
  166. default:
  167. return "warning"
  168. }
  169. default:
  170. switch {
  171. case r < 70:
  172. return "info"
  173. case r < 95:
  174. return "warning"
  175. case r < 99:
  176. return "critical"
  177. default:
  178. return "inminent_colapse"
  179. }
  180. }
  181. }
  182. func isUnique(dedupePct, idx int) bool {
  183. if dedupePct == 0 || idx == 0 {
  184. return true
  185. }
  186. return rand.IntN(100) >= dedupePct
  187. }