m6_smoke.sh 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228
  1. #!/usr/bin/env bash
  2. # Live M6 smoke test. Run from repo root:
  3. # bash scripts/m6_smoke.sh
  4. #
  5. # Walks through the 5 scenarios in M6_VERIFICATION.md:
  6. #
  7. # Step 2 — sliding-window ×N: 5 identical alerts → recipient sees
  8. # the (×5) suffix on the 5th message
  9. # Step 3 — dedupe_count metric: max-observed gauge and
  10. # dedupe_collapsed counter both tick
  11. # Step 4 — sliding TTL: a duplicate after > TTL is a fresh "new"
  12. # Step 5 — free-for-dupes: 200 dupes/s never trigger the rate limit
  13. # (per-source cap is 100/s, but only the first alert burns
  14. # a token; the other 199 are free)
  15. # Step 6 — per-source key isolation: same dedupe_key from a
  16. # different source does NOT collide
  17. #
  18. # Exit code is the number of failed checks.
  19. set -e
  20. cd "$(dirname "$0")/.."
  21. PG="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
  22. INGESTD_METRICS=http://localhost:8800/metrics
  23. FAKETGMD=http://localhost:8830
  24. WS_INGEST=ws://localhost:8800/v1/ingest/ws
  25. SRC_ACME=acme-001:prom-prod:s3cret-acme
  26. SRC_GLOBEX=globex-002:grafana:s3cret-globex
  27. fails=0
  28. pass() { echo " ✅ $*"; }
  29. fail() { echo " ❌ $*"; fails=$((fails+1)); }
  30. reset_state() {
  31. $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL, telegram_invite_code = 'acme-bob-002' WHERE id = 'ind-acme-002';" >/dev/null
  32. $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL WHERE id = 'ind-acme-003';" >/dev/null
  33. $PG -c "UPDATE subscriptions SET min_severity = 'critical' WHERE individual_id = 'ind-acme-002' AND source_id = 'prom-prod';" >/dev/null
  34. $PG -c "TRUNCATE deliveries;" >/dev/null
  35. curl -sS -X POST $FAKETGMD/admin/reset >/dev/null
  36. }
  37. # Per-source dedupe metric readers.
  38. dedupe_collapsed() {
  39. # $1 = source_id
  40. curl -sS "$INGESTD_METRICS" 2>/dev/null | \
  41. grep -E "^ba_ingestd_dedupe_collapsed_total\{[^}]*source=\"$1\"" | \
  42. awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
  43. }
  44. dedupe_max() {
  45. # $1 = source_id
  46. curl -sS "$INGESTD_METRICS" 2>/dev/null | \
  47. grep -E "^ba_ingestd_dedupe_count_max_observed\{[^}]*source=\"$1\"" | \
  48. awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
  49. }
  50. ws_messages() {
  51. # $1 = result label
  52. curl -sS "$INGESTD_METRICS" 2>/dev/null | \
  53. grep -E "^ba_ingestd_ws_messages_total\{result=\"$1\"" | \
  54. awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
  55. }
  56. # ── Setup: build loadgen-ws ──
  57. if [[ ! -x /tmp/loadgen-ws-m6 ]]; then
  58. echo "▸ Building /tmp/loadgen-ws-m6"
  59. (cd loadgen && CGO_ENABLED=0 go build -o /tmp/loadgen-ws-m6 ./cmd/ws)
  60. fi
  61. # ─────────────────────────────────────────────────────────────────
  62. echo "── M6 smoke — Dedupe + ×N suffix + sliding TTL + free-for-dupes ──"
  63. echo ""
  64. # ── Step 2: ×N suffix on the 5th message ───────────────────
  65. echo "── Step 2: 5 identical alerts → (×5) suffix on the last ──"
  66. reset_state
  67. sleep 1 # let the dedupe window drain
  68. KEY="m6-step2-$(date +%s%N)"
  69. # We use a unique key per run so the dedupe count starts at 1.
  70. /tmp/loadgen-ws-m6 --target "$WS_INGEST" --api-key "$SRC_ACME" --count 5 --rate 10 --dedupe-key "$KEY" 2>&1 | tail -2
  71. sleep 3
  72. # Read the last faketgmd message; expect "(×5)" in the title.
  73. last_text=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); items=d.get("items",[]); print(items[-1]["text"] if items else "")' 2>/dev/null)
  74. if echo "$last_text" | grep -q '(×5)'; then
  75. pass "faketgmd last message contains '(×5)'"
  76. else
  77. fail "faketgmd last message: $last_text (expected '(×5)' suffix)"
  78. fi
  79. # ── Step 3: metrics tick correctly ────────────────────────
  80. echo ""
  81. echo "── Step 3: dedupe_collapsed and dedupe_count_max_observed both tick ──"
  82. collapsed_before=$(dedupe_collapsed prom-prod)
  83. max_before=$(dedupe_max prom-prod)
  84. collapsed_before=${collapsed_before:-0}
  85. max_before=${max_before:-0}
  86. KEY="m6-step3-$(date +%s%N)"
  87. /tmp/loadgen-ws-m6 --target "$WS_INGEST" --api-key "$SRC_ACME" --count 5 --rate 10 --dedupe-key "$KEY" 2>&1 | tail -2
  88. sleep 2
  89. collapsed_after=$(dedupe_collapsed prom-prod)
  90. max_after=$(dedupe_max prom-prod)
  91. collapsed_after=${collapsed_after:-0}
  92. max_after=${max_after:-0}
  93. collapsed_delta=$((collapsed_after - collapsed_before))
  94. max_delta=$((max_after - max_before))
  95. if [[ $collapsed_delta -ge 4 ]]; then
  96. pass "dedupe_collapsed_total{source=\"prom-prod\"} +$collapsed_delta (≥4 of 5 are dupes)"
  97. else
  98. fail "dedupe_collapsed_total delta was $collapsed_delta (expected ≥4)"
  99. fi
  100. if [[ $max_after -ge 5 ]]; then
  101. pass "dedupe_count_max_observed{source=\"prom-prod\"} = $max_after (≥5)"
  102. else
  103. fail "dedupe_count_max_observed was $max_after (expected ≥5)"
  104. fi
  105. # ── Step 4: sliding TTL — a fresh dedupe_key is "new" ──────
  106. echo ""
  107. echo "── Step 4: fresh dedupe_key (no collision with prior burst) → all accepted as new ──"
  108. reset_state
  109. sleep 1
  110. # Use a brand new key; the loadgen's normal profile would also
  111. # pick a unique key per alert, but to keep this deterministic
  112. # we use --dedupe-key with a timestamp.
  113. KEY="m6-step4-$(date +%s%N)"
  114. recv_before=$(ws_messages received)
  115. acc_before=$(ws_messages accepted)
  116. /tmp/loadgen-ws-m6 --target "$WS_INGEST" --api-key "$SRC_ACME" --count 3 --rate 5 --dedupe-key "$KEY" 2>&1 | tail -2
  117. sleep 2
  118. recv_after=$(ws_messages received)
  119. acc_after=$(ws_messages accepted)
  120. recv_delta=$((recv_after - recv_before))
  121. acc_delta=$((acc_after - acc_before))
  122. if [[ $recv_delta -eq 3 ]]; then
  123. pass "ws_messages_total{result=\"received\"} +$recv_delta"
  124. else
  125. fail "ws_messages_total{result=\"received\"} delta was $recv_delta (expected 3)"
  126. fi
  127. if [[ $acc_delta -eq 3 ]]; then
  128. pass "ws_messages_total{result=\"accepted\"} +$acc_delta (all 3 new, fresh key)"
  129. else
  130. fail "ws_messages_total{result=\"accepted\"} delta was $acc_delta (expected 3)"
  131. fi
  132. # ── Step 5: free-for-dupes — rate limit never fires on a dupe storm ──
  133. echo ""
  134. echo "── Step 5: 200 dupes/s (10× source rate limit) → no rate_limited_source ──"
  135. # The default per-source rate is 100/s in docker-compose. We
  136. # blast 300 dupes/s (3× over) for 2 seconds and confirm the
  137. # rate_limited_source counter doesn't tick.
  138. reset_state
  139. rl_before=$(curl -sS "$INGESTD_METRICS" 2>/dev/null | grep -E '^ba_ingestd_rate_limit_hits_total\{[^}]*scope="source"' | awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1)
  140. rl_before=${rl_before:-0}
  141. ws_acc_before=$(ws_messages accepted)
  142. KEY="m6-step5-$(date +%s%N)"
  143. /tmp/loadgen-ws-m6 --target "$WS_INGEST" --api-key "$SRC_ACME" --count 600 --rate 300 --dedupe-key "$KEY" 2>&1 | tail -2
  144. sleep 3
  145. rl_after=$(curl -sS "$INGESTD_METRICS" 2>/dev/null | grep -E '^ba_ingestd_rate_limit_hits_total\{[^}]*scope="source"' | awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1)
  146. ws_acc_after=$(ws_messages accepted)
  147. rl_delta=$((rl_after - rl_before))
  148. accepted_delta=$((ws_acc_after - ws_acc_before))
  149. if [[ $rl_delta -eq 0 ]]; then
  150. pass "rate_limit_hits_total{scope=\"source\"} did not tick (free-for-dupes works)"
  151. else
  152. fail "rate_limit_hits_total{scope=\"source\"} +$rl_delta (expected 0)"
  153. fi
  154. if [[ $accepted_delta -ge 500 ]]; then
  155. pass "ws_messages_total{result=\"accepted\"} +$accepted_delta (≥500 of 600 accepted)"
  156. else
  157. fail "ws_messages_total{result=\"accepted\"} +$accepted_delta (expected ≥500)"
  158. fi
  159. # ── Step 6: per-source isolation ─────────────────────────
  160. echo ""
  161. echo "── Step 6: same dedupe_key from a different source does NOT collide ──"
  162. reset_state
  163. sleep 1
  164. KEY="m6-step6-$(date +%s%N)"
  165. globex_max_before=$(dedupe_max grafana)
  166. globex_max_before=${globex_max_before:-0}
  167. # Send 3 from acme, 3 from globex, all with the same key.
  168. # Expect: acme counts 1,2,3; globex counts 1,2,3 (no cross-pollination).
  169. /tmp/loadgen-ws-m6 --target "$WS_INGEST" --api-key "$SRC_ACME" --count 3 --rate 5 --dedupe-key "$KEY" 2>&1 | tail -2
  170. sleep 1
  171. /tmp/loadgen-ws-m6 --target "$WS_INGEST" --api-key "$SRC_GLOBEX" --count 3 --rate 5 --dedupe-key "$KEY" 2>&1 | tail -2
  172. sleep 2
  173. globex_max_after=$(dedupe_max grafana)
  174. globex_max_after=${globex_max_after:-0}
  175. if [[ $globex_max_after -ge 3 ]]; then
  176. pass "dedupe_count_max_observed{source=\"grafana\"} = $globex_max_after (≥3 from globex)"
  177. else
  178. fail "dedupe_count_max_observed{source=\"grafana\"} was $globex_max_after (expected ≥3)"
  179. fi
  180. # Also: acme's max should be ≥3 but not affected by globex's hits
  181. # (it should be the same as it was after Step 5's storm). We
  182. # don't assert a delta here — just that the gauge is non-zero.
  183. acme_max=$(dedupe_max prom-prod)
  184. acme_max=${acme_max:-0}
  185. if [[ $acme_max -ge 1 ]]; then
  186. pass "dedupe_count_max_observed{source=\"prom-prod\"} = $acme_max (per-source isolation preserved)"
  187. else
  188. fail "dedupe_count_max_observed{source=\"prom-prod\"} was $acme_max (expected ≥1)"
  189. fi
  190. # ── Summary ─────────────────────────────────────────────────
  191. echo ""
  192. if [[ $fails -eq 0 ]]; then
  193. echo "🟢 M6 smoke PASS — all checks green"
  194. exit 0
  195. else
  196. echo "🔴 M6 smoke FAIL — $fails check(s) failed"
  197. exit $fails
  198. fi