main.go 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  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. dedupeKey = flag.String("dedupe-key", "", "force a specific dedupe_key on every alert (M6 ×N smoke test)")
  44. timeout = flag.Duration("duration", 30*time.Second, "max run time")
  45. concurrencyFlag = flag.Int("concurrency", 1, "parallel WS connections (each one is one source)")
  46. )
  47. flag.Parse()
  48. if *apiKey == "" {
  49. fmt.Fprintln(os.Stderr, "loadgen-ws: --api-key is required (company:source:secret)")
  50. os.Exit(2)
  51. }
  52. parts := strings.SplitN(*apiKey, ":", 3)
  53. if len(parts) != 3 {
  54. fmt.Fprintln(os.Stderr, "loadgen-ws: --api-key must be company:source:secret")
  55. os.Exit(2)
  56. }
  57. company, source, secret := parts[0], parts[1], parts[2]
  58. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  59. _, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  60. defer stop()
  61. logger.Info("publishing",
  62. "target", *target,
  63. "count", *count, "rate", *rate, "mode", *mode,
  64. "concurrency", *concurrencyFlag,
  65. )
  66. var (
  67. sent atomic.Uint64
  68. failed atomic.Uint64
  69. dupes atomic.Uint64
  70. idxCh = make(chan int, *count)
  71. )
  72. for i := 0; i < *count; i++ {
  73. idxCh <- i
  74. }
  75. close(idxCh)
  76. limiter := time.NewTicker(time.Second / time.Duration(*rate))
  77. defer limiter.Stop()
  78. deadline := time.Now().Add(*timeout)
  79. var wg sync.WaitGroup
  80. for w := 0; w < *concurrencyFlag; w++ {
  81. wg.Add(1)
  82. go func(workerID int) {
  83. defer wg.Done()
  84. c, err := wsclient.Connect(wsclient.Config{
  85. URL: *target,
  86. APIKey: *apiKey,
  87. })
  88. if err != nil {
  89. logger.Error("ws connect", "worker", workerID, "err", err)
  90. failed.Add(1)
  91. return
  92. }
  93. defer c.Close()
  94. logger.Info("ws connected", "worker", workerID, "auth_reply", string(c.AuthReply()))
  95. for i := range idxCh {
  96. if time.Now().After(deadline) {
  97. return
  98. }
  99. <-limiter.C
  100. a := makeAlert(i, *mode, *dedupePct, *dedupeKey, company, source)
  101. body, _ := json.Marshal(a)
  102. ts := time.Now().Unix()
  103. mac := hmac.New(sha256.New, []byte(secret))
  104. mac.Write([]byte(fmt.Sprintf("%d", ts)))
  105. mac.Write([]byte("."))
  106. mac.Write(body)
  107. sig := hex.EncodeToString(mac.Sum(nil))
  108. env := map[string]json.RawMessage{
  109. "alert": body,
  110. }
  111. env["auth"] = json.RawMessage(fmt.Sprintf("%q", fmt.Sprintf("t=%d,v1=%s", ts, sig)))
  112. envelope, _ := json.Marshal(env)
  113. ack, err := c.SendAlert(envelope)
  114. if err != nil {
  115. failed.Add(1)
  116. logger.Warn("ws send", "err", err, "i", i)
  117. continue
  118. }
  119. if !isUnique(*dedupePct, i) {
  120. dupes.Add(1)
  121. }
  122. sent.Add(1)
  123. if i == 0 || (i+1)%(*count/10+1) == 0 {
  124. logger.Info("progress", "worker", workerID, "sent", i+1, "total", *count, "ack", string(ack))
  125. }
  126. }
  127. }(w)
  128. }
  129. wg.Wait()
  130. logger.Info("done",
  131. "sent", sent.Load(),
  132. "failed", failed.Load(),
  133. "dupes", dupes.Load(),
  134. )
  135. if failed.Load() > 0 {
  136. os.Exit(1)
  137. }
  138. }
  139. // makeAlert is the same shape as loadgen/cmd/http and
  140. // loadgen/cmd/mqtt.
  141. func makeAlert(idx int, mode string, dedupePct int, forceKey, company, source string) map[string]any {
  142. severity := pickSeverity(mode)
  143. // M6: --dedupe-key forces a specific key on every alert,
  144. // which is what the ×N smoke test needs. Otherwise we
  145. // use the M5 distribution: 30% share a key, 70% get a
  146. // unique one.
  147. dedupeKey := fmt.Sprintf("lg-m5-%d", idx)
  148. if forceKey != "" {
  149. dedupeKey = forceKey
  150. } else if dedupePct > 0 && idx > 0 && rand.IntN(100) < dedupePct {
  151. dedupeKey = "lg-m5-shared"
  152. }
  153. title := fmt.Sprintf("LG M5 #%d", idx)
  154. if forceKey != "" {
  155. // M6.5 smoke: include the forced key in the title so
  156. // the tail subscriber can filter on a unique string
  157. // (the tail event includes title but not dedupe_key).
  158. title = fmt.Sprintf("LG M5 burst %s #%d", forceKey, idx)
  159. }
  160. return map[string]any{
  161. "company_id": company,
  162. "source_id": source,
  163. "severity": severity,
  164. "category": "loadgen",
  165. "title": title,
  166. "body": "ws smoke",
  167. "data": map[string]string{"host": "lg-host", "idx": fmt.Sprintf("%d", idx)},
  168. "dedupe_key": dedupeKey,
  169. }
  170. }
  171. func pickSeverity(mode string) string {
  172. r := rand.IntN(100)
  173. switch mode {
  174. case "burst":
  175. switch {
  176. case r < 70:
  177. return "critical"
  178. case r < 95:
  179. return "inminent_colapse"
  180. default:
  181. return "warning"
  182. }
  183. default:
  184. switch {
  185. case r < 70:
  186. return "info"
  187. case r < 95:
  188. return "warning"
  189. case r < 99:
  190. return "critical"
  191. default:
  192. return "inminent_colapse"
  193. }
  194. }
  195. }
  196. func isUnique(dedupePct, idx int) bool {
  197. if dedupePct == 0 || idx == 0 {
  198. return true
  199. }
  200. return rand.IntN(100) >= dedupePct
  201. }