main.go 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. // loadgen/cmd/mqtt is the M4 MQTT publisher for broad-announce.
  2. // Same data shape as loadgen/cmd/http (severity mix, dedupe
  3. // ratio, company cycling) but posts to an MQTT broker on
  4. // ba/<co>/<src>/incoming. Auth is twofold:
  5. //
  6. // 1. MQTT username/password: prom-prod-acme-001 / s3cret-acme
  7. // (the EMQX built-in-db row from deploy/emqx/auth-built-in-
  8. // db-bootstrap.csv). The broker's ACL is keyed on the
  9. // username, so this is what gates topic access.
  10. // 2. Per-message X-BA-Signature in the JSON envelope's `auth`
  11. // field, identical to HTTP. This is the per-message HMAC
  12. // the ingestd mqtt subscriber re-uses verifyHMAC() to check.
  13. //
  14. // Example:
  15. //
  16. // loadgen-mqtt --broker tcp://localhost:1883 \
  17. // --api-key acme-001:prom-prod:s3cret-acme \
  18. // --count 10 --rate 5
  19. package main
  20. import (
  21. "context"
  22. "crypto/hmac"
  23. "crypto/sha256"
  24. "encoding/hex"
  25. "encoding/json"
  26. "flag"
  27. "fmt"
  28. "log/slog"
  29. "math/rand/v2"
  30. "net/http"
  31. "os"
  32. "os/signal"
  33. "strings"
  34. "sync"
  35. "sync/atomic"
  36. "syscall"
  37. "time"
  38. "git3.techno-world.net/lrosales/broad-announce/internal/mqttclient"
  39. )
  40. func main() {
  41. var (
  42. broker = flag.String("broker", "tcp://localhost:1883", "MQTT broker URL")
  43. apiKey = flag.String("api-key", "", "company:source:secret")
  44. count = flag.Int("count", 10, "total alerts to send")
  45. rate = flag.Int("rate", 10, "target alerts/sec")
  46. rampUp = flag.Duration("ramp-up", 0, "linear ramp from 0 to --rate over this duration (default 0 = instant)")
  47. mode = flag.String("mode", "normal", "profile: normal|burst")
  48. dedupePct = flag.Int("dedupe-pct", 30, "percent sharing a dedupe_key (normal)")
  49. dedupeKey = flag.String("dedupe-key", "", "force a specific dedupe_key on every alert")
  50. metrics = flag.String("metrics", "", "Prometheus metrics listen addr (empty to disable)")
  51. timeout = flag.Duration("duration", 30*time.Second, "max run time")
  52. clusterID = flag.String("cluster-id", "default", "tag added as label on all loadgen metrics")
  53. instance = flag.String("instance", "default", "loadgen instance name (e.g. loadgen-mqtt-1)")
  54. )
  55. flag.Parse()
  56. if *apiKey == "" {
  57. fmt.Fprintln(os.Stderr, "loadgen-mqtt: --api-key is required (company:source:secret)")
  58. os.Exit(2)
  59. }
  60. parts := strings.SplitN(*apiKey, ":", 3)
  61. if len(parts) != 3 {
  62. fmt.Fprintln(os.Stderr, "loadgen-mqtt: --api-key must be company:source:secret")
  63. os.Exit(2)
  64. }
  65. company, source, secret := parts[0], parts[1], parts[2]
  66. username := fmt.Sprintf("%s-%s", source, company) // matches auth csv
  67. logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
  68. logger.Info("starting",
  69. "broker", *broker,
  70. "rate", *rate,
  71. "ramp_up", *rampUp,
  72. "count", *count,
  73. "duration", *timeout,
  74. "cluster_id", *clusterID,
  75. "instance", *instance,
  76. )
  77. ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
  78. defer stop()
  79. host, _ := os.Hostname()
  80. client, err := mqttclient.Connect(ctx, mqttclient.Config{
  81. Broker: *broker,
  82. ClientID: fmt.Sprintf("loadgen-mqtt-%s", host),
  83. Username: username,
  84. Password: secret,
  85. Clean: true,
  86. }, logger)
  87. if err != nil {
  88. fmt.Fprintln(os.Stderr, "loadgen-mqtt: connect:", err)
  89. os.Exit(1)
  90. }
  91. defer client.Disconnect()
  92. topic := fmt.Sprintf("ba/%s/%s/incoming", company, source)
  93. logger.Info("publishing", "topic", topic, "count", *count, "rate", *rate, "mode", *mode)
  94. var (
  95. sent atomic.Uint64
  96. failed atomic.Uint64
  97. dupes atomic.Uint64
  98. )
  99. limiter := time.NewTicker(time.Second / time.Duration(*rate))
  100. defer limiter.Stop()
  101. var wg sync.WaitGroup
  102. work := make(chan int, 64)
  103. wg.Add(1)
  104. go func() {
  105. defer wg.Done()
  106. for i := 0; i < *count; i++ {
  107. work <- i
  108. }
  109. close(work)
  110. }()
  111. deadline := time.Now().Add(*timeout)
  112. for i := range work {
  113. if time.Now().After(deadline) {
  114. logger.Warn("deadline reached, stopping")
  115. break
  116. }
  117. <-limiter.C
  118. a := makeAlert(i, *mode, *dedupePct, *dedupeKey, company, source)
  119. ts := time.Now().Unix()
  120. // HTTP-style signature over the alert body, with t= prefix.
  121. // The MQTT subscriber's verifyHMAC accepts the same shape.
  122. body, _ := json.Marshal(a)
  123. mac := hmac.New(sha256.New, []byte(secret))
  124. mac.Write([]byte(fmt.Sprintf("%d", ts)))
  125. mac.Write([]byte("."))
  126. mac.Write(body)
  127. sig := hex.EncodeToString(mac.Sum(nil))
  128. env := map[string]json.RawMessage{
  129. "alert": body,
  130. }
  131. env["auth"] = json.RawMessage(fmt.Sprintf("%q", fmt.Sprintf("t=%d,v1=%s", ts, sig)))
  132. envelope, _ := json.Marshal(env)
  133. if err := client.Publish(topic, envelope); err != nil {
  134. failed.Add(1)
  135. logger.Warn("publish failed", "err", err, "i", i)
  136. continue
  137. }
  138. if i > 0 && i%(*count/10+1) == 0 {
  139. logger.Info("progress", "sent", i, "total", *count)
  140. }
  141. if !*isUnique(*dedupePct, i) {
  142. dupes.Add(1)
  143. }
  144. sent.Add(1)
  145. }
  146. wg.Wait()
  147. logger.Info("done",
  148. "sent", sent.Load(),
  149. "failed", failed.Load(),
  150. "dupes", dupes.Load(),
  151. )
  152. if failed.Load() > 0 {
  153. os.Exit(1)
  154. }
  155. // Metrics endpoint (registered but currently a no-op; wired in M10)
  156. if *metrics != "" {
  157. go runMQTTMetrics(*metrics, *clusterID, *instance, &sent, &failed, &dupes)
  158. }
  159. }
  160. // makeAlert generates one alert. Same shape as loadgen/cmd/http.
  161. func makeAlert(idx int, mode string, dedupePct int, forceKey, company, source string) map[string]any {
  162. severity := pickSeverity(mode)
  163. // M6: --dedupe-key forces a specific key on every alert,
  164. // which is what the ×N smoke test needs. Otherwise we
  165. // use the M4 distribution: 30% share a key, 70% get a
  166. // unique one.
  167. dedupeKey := fmt.Sprintf("lg-m4-%d", idx)
  168. if forceKey != "" {
  169. dedupeKey = forceKey
  170. } else if dedupePct > 0 && idx > 0 && rand.IntN(100) < dedupePct {
  171. dedupeKey = "lg-m4-shared"
  172. }
  173. return map[string]any{
  174. "company_id": company,
  175. "source_id": source,
  176. "severity": severity,
  177. "category": "loadgen",
  178. "title": fmt.Sprintf("LG M4 #%d", idx),
  179. "body": "mqtt smoke",
  180. "data": map[string]string{"host": "lg-host", "idx": fmt.Sprintf("%d", idx)},
  181. "dedupe_key": dedupeKey,
  182. }
  183. }
  184. func pickSeverity(mode string) string {
  185. r := rand.IntN(100)
  186. switch mode {
  187. case "burst":
  188. // Mostly critical to exercise the bypass.
  189. switch {
  190. case r < 70:
  191. return "critical"
  192. case r < 95:
  193. return "inminent_colapse"
  194. default:
  195. return "warning"
  196. }
  197. default: // "normal"
  198. switch {
  199. case r < 70:
  200. return "info"
  201. case r < 95:
  202. return "warning"
  203. case r < 99:
  204. return "critical"
  205. default:
  206. return "inminent_colapse"
  207. }
  208. }
  209. }
  210. func isUnique(dedupePct, i int) *bool {
  211. b := i == 0 || dedupePct == 0 || rand.IntN(100) >= dedupePct
  212. return &b
  213. }
  214. func runMQTTMetrics(addr, clusterID, instance string, sent, failed, dupes *atomic.Uint64) {
  215. mux := http.NewServeMux()
  216. mux.HandleFunc("/metrics", func(w http.ResponseWriter, r *http.Request) {
  217. fmt.Fprintf(w, "# HELP loadgen_alerts_sent_total Alerts successfully accepted.\n")
  218. fmt.Fprintf(w, "# TYPE loadgen_alerts_sent_total counter\n")
  219. fmt.Fprintf(w, "loadgen_alerts_sent_total{instance=%q,cluster_id=%q} %d\n",
  220. instance, clusterID, sent.Load())
  221. fmt.Fprintf(w, "loadgen_alerts_failed_total{instance=%q,cluster_id=%q} %d\n",
  222. instance, clusterID, failed.Load())
  223. fmt.Fprintf(w, "loadgen_dedupe_hits_total{instance=%q,cluster_id=%q} %d\n",
  224. instance, clusterID, dupes.Load())
  225. })
  226. srv := &http.Server{Addr: addr, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
  227. _ = srv.ListenAndServe()
  228. }