m6.5_smoke.sh 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. #!/usr/bin/env bash
  2. # Live M6.5 smoke test. Run from repo root:
  3. # bash scripts/m6.5_smoke.sh
  4. #
  5. # Walks through the 4 scenarios in M6.5_VERIFICATION.md:
  6. #
  7. # Step 2 — single collapse: 100 alerts with the same
  8. # --dedupe-key → faketgmd /admin/sent shows
  9. # count=1, text contains '(×100)'. (This is the
  10. # headline M6.5 win: the recipient gets ONE
  11. # message, not 100.)
  12. # Step 3 — tail visibility: the WS tail still shows
  13. # every arrival (operators see the storm live),
  14. # even though only 1 delivery went out.
  15. # Step 4 — max-wait re-flush: a 6s continuous burst
  16. # re-flushes every 2s. The recipient gets 3+
  17. # messages, each with the count-so-far.
  18. # Step 5 — per-source isolation: same --dedupe-key from
  19. # acme (prom-prod) and globex (grafana) does
  20. # NOT collapse into one — separate state, two
  21. # messages.
  22. # Step 6 — empty key passthrough: the M2 path is
  23. # preserved — alerts with empty dedupe_key
  24. # deliver immediately. (The loadgen normal
  25. # profile gives each alert a unique key, so
  26. # 3 alerts produce 3 deliveries — i.e. no
  27. # collapse happens for keys we never see twice.)
  28. #
  29. # Exit code is the number of failed checks.
  30. set -e
  31. cd "$(dirname "$0")/.."
  32. INGESTD_METRICS=http://localhost:8800/metrics
  33. FAKETGMD=http://localhost:8830
  34. WS_INGEST=ws://localhost:8800/v1/ingest/ws
  35. WS_TAIL=ws://localhost:8800/v1/tail/ws
  36. SRC_ACME=acme-001:prom-prod:s3cret-acme
  37. SRC_GLOBEX=globex-002:grafana:s3cret-globex
  38. TAIL_TOKEN=tail-dev-token-please-change-in-prod
  39. BA_FLUSH_MS=${BA_ROUTERD_DEDUPE_FLUSH_MS:-2000}
  40. fails=0
  41. pass() { echo " ✅ $*"; }
  42. fail() { echo " ❌ $*"; fails=$((fails+1)); }
  43. # Seed globex company + source + individual + subscription.
  44. # Done once at the top of the script (idempotent), not in
  45. # reset_state — the schema's foreign keys make per-call seed
  46. # noisy. Step 5 needs globex to actually have a recipient.
  47. PGCMD="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
  48. $PGCMD -c \
  49. "INSERT INTO companies (id, name) VALUES ('globex-002', 'Globex Corp') ON CONFLICT (id) DO NOTHING;" \
  50. >/dev/null 2>&1 || true
  51. $PGCMD -c \
  52. "INSERT INTO sources (id, company_id, name) VALUES ('grafana', 'globex-002', 'Globex Grafana') ON CONFLICT (company_id, id) DO NOTHING;" \
  53. >/dev/null 2>&1 || true
  54. $PGCMD -c \
  55. "INSERT INTO individuals (id, company_id, full_name, telegram_chat_id) VALUES ('ind-globex-001', 'globex-002', 'Globex Test', 2001) ON CONFLICT (id) DO UPDATE SET telegram_chat_id = 2001;" \
  56. >/dev/null 2>&1 || true
  57. $PGCMD -c \
  58. "INSERT INTO subscriptions (individual_id, company_id, source_id, channel_mask, min_severity, tz, status) VALUES ('ind-globex-001', 'globex-002', 'grafana', '[\"fcm\",\"telegram\"]'::jsonb, '', 'UTC', 'active') ON CONFLICT (individual_id, source_id) DO UPDATE SET channel_mask = '[\"fcm\",\"telegram\"]'::jsonb;" \
  59. >/dev/null 2>&1 || true
  60. reset_state() {
  61. curl -sS -X POST $FAKETGMD/admin/reset >/dev/null
  62. # deliveries are kept (we want to count them across steps)
  63. }
  64. # ── Setup: build loadgen-ws and tailcount ──
  65. if [[ ! -x /tmp/loadgen-ws-m65 ]]; then
  66. echo "▸ Building /tmp/loadgen-ws-m65"
  67. (cd loadgen && CGO_ENABLED=0 go build -o /tmp/loadgen-ws-m65 ./cmd/ws)
  68. fi
  69. if [[ ! -x /tmp/tailcount ]]; then
  70. echo "▸ Building /tmp/tailcount"
  71. (cd testfakes/tailcount && CGO_ENABLED=0 go build -o /tmp/tailcount)
  72. fi
  73. # ─────────────────────────────────────────────────────────────────
  74. echo "── M6.5 smoke — Router-level dedupe collapse ──"
  75. echo ""
  76. # ── Step 2: single collapse (100 alerts → 1 message) ─────
  77. echo "── Step 2: 100 alerts with same --dedupe-key → 1 message, (×100) ──"
  78. reset_state
  79. # Drain the dedupe state for our key. We use a timestamp
  80. # suffix so each run is isolated.
  81. KEY="m65-step2-$(date +%s%N)"
  82. /tmp/loadgen-ws-m65 --target "$WS_INGEST" --api-key "$SRC_ACME" \
  83. --count 100 --rate 50 --dedupe-key "$KEY" 2>&1 | tail -2
  84. # Wait for the router's flush (BA_ROUTERD_DEDUPE_FLUSH_MS)
  85. # plus a margin for the deliverer round-trip.
  86. sleep_secs=$(awk "BEGIN { print int($BA_FLUSH_MS/1000) + 3 }")
  87. sleep "$sleep_secs"
  88. count=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("count",0))' 2>/dev/null)
  89. if [[ "$count" == "1" ]]; then
  90. pass "faketgmd /admin/sent count = 1 (100 alerts collapsed to 1 delivery)"
  91. else
  92. fail "faketgmd /admin/sent count = $count (expected 1)"
  93. fi
  94. 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)
  95. if echo "$last_text" | grep -q '(×100)'; then
  96. pass "faketgmd last message contains '(×100)'"
  97. else
  98. fail "faketgmd last message: $last_text (expected '(×100)' suffix)"
  99. fi
  100. # ── Step 3: tail still shows the storm ────────────────────
  101. echo ""
  102. echo "── Step 3: WS tail sees every arrival (operators see the storm live) ──"
  103. reset_state
  104. KEY="m65-step3-$(date +%s%N)"
  105. # Subscribe to the tail in the background, filtered to the
  106. # title pattern that the loadgen's makeAlert puts in when
  107. # --dedupe-key is set ("LG M5 burst <key> #N"). The compact
  108. # tail event includes title but NOT dedupe_key, so we filter
  109. # on the title.
  110. /tmp/tailcount -token "$TAIL_TOKEN" -company "acme-001" \
  111. -filter "burst $KEY" -timeout 12s > /tmp/m65-step3-tail.out 2>&1 &
  112. TAIL_PID=$!
  113. # Give the tail 1.5s to connect.
  114. sleep 1.5
  115. /tmp/loadgen-ws-m65 --target "$WS_INGEST" --api-key "$SRC_ACME" \
  116. --count 25 --rate 50 --dedupe-key "$KEY" 2>&1 | tail -1
  117. # Wait for the tailcount to finish.
  118. wait $TAIL_PID 2>/dev/null || true
  119. tail_count=$(grep -oE 'count=[0-9]+' /tmp/m65-step3-tail.out | tail -1 | cut -d= -f2)
  120. tail_count=${tail_count:-0}
  121. if [[ $tail_count -ge 20 ]]; then
  122. pass "tail subscriber saw $tail_count events (≥20 of 25 alerts streamed live)"
  123. else
  124. fail "tail saw $tail_count events (expected ≥20, output=$(cat /tmp/m65-step3-tail.out))"
  125. fi
  126. # And the recipient still only got 1.
  127. sleep "$sleep_secs"
  128. count=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("count",0))' 2>/dev/null)
  129. if [[ "$count" == "1" ]]; then
  130. pass "faketgmd count = 1 (collapse still applied — tail ≠ delivery)"
  131. else
  132. fail "faketgmd count = $count (expected 1)"
  133. fi
  134. # ── Step 4: max-wait re-flush (continuous stream) ────────
  135. echo ""
  136. echo "── Step 4: 6s continuous burst at 30/s → ≥3 re-flushes ──"
  137. reset_state
  138. KEY="m65-step4-$(date +%s%N)"
  139. /tmp/loadgen-ws-m65 --target "$WS_INGEST" --api-key "$SRC_ACME" \
  140. --count 180 --rate 30 --dedupe-key "$KEY" 2>&1 | tail -1
  141. # 180 / 30 = 6 seconds of streaming, well above 3 flush windows
  142. # at BA_ROUTERD_DEDUPE_FLUSH_MS=2000.
  143. sleep "$sleep_secs"
  144. count=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("count",0))' 2>/dev/null)
  145. if [[ $count -ge 2 && $count -le 5 ]]; then
  146. pass "faketgmd count = $count (expected 2..5 re-flushes over 6s @ flushMs=$BA_FLUSH_MS)"
  147. else
  148. fail "faketgmd count = $count (expected 2..5 re-flushes)"
  149. fi
  150. # ── Step 5: per-source isolation ─────────────────────────
  151. echo ""
  152. echo "── Step 5: same --dedupe-key from acme and globex → 2 separate collapses ──"
  153. reset_state
  154. KEY="m65-step5-$(date +%s%N)"
  155. # 5 from acme, 5 from globex, same key.
  156. # The two sources have separate Collapser state, so each
  157. # gets 1 delivery. Total: 2 messages.
  158. # acme subscription is on 'prom-prod'; globex is on 'grafana'.
  159. /tmp/loadgen-ws-m65 --target "$WS_INGEST" --api-key "$SRC_ACME" \
  160. --count 5 --rate 5 --dedupe-key "$KEY" 2>&1 | tail -1
  161. sleep 1
  162. /tmp/loadgen-ws-m65 --target "$WS_INGEST" --api-key "$SRC_GLOBEX" \
  163. --count 5 --rate 5 --dedupe-key "$KEY" 2>&1 | tail -1
  164. sleep "$sleep_secs"
  165. count=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("count",0))' 2>/dev/null)
  166. # We expect 2: one from acme's chat (1001) and one from
  167. # globex's chat (2001). The faketgmd log groups by
  168. # (bot_token, chat_id), so 2 distinct recipients show
  169. # up as 2 items in /admin/sent.
  170. if [[ "$count" == "2" ]]; then
  171. pass "faketgmd count = 2 (one per source — per-source isolation holds)"
  172. else
  173. fail "faketgmd count = $count (expected 2: one per source)"
  174. fi
  175. # Verify the messages are from different chats.
  176. chats=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); items=d.get("items",[]); print(",".join(sorted(set(str(i.get("chat_id")) for i in items))))' 2>/dev/null)
  177. if [[ "$chats" == "1001,2001" ]]; then
  178. pass "chat_ids = $chats (acme=1001, globex=2001)"
  179. else
  180. fail "chat_ids = $chats (expected 1001,2001)"
  181. fi
  182. # ── Step 6: passthrough — unique keys don't collapse ──────
  183. echo ""
  184. echo "── Step 6: loadgen normal profile (unique keys per alert) → no collapse ──"
  185. reset_state
  186. # The loadgen's normal profile gives each alert a unique
  187. # dedupe_key (lg-m5-0, lg-m5-1, ...) — 30% share, but with
  188. # idx>0 and only 3 alerts, in practice they're all unique.
  189. # The Collapser's Passthrough path is for empty dedupe_key;
  190. # the unique-keys path uses CollapseNew, which still
  191. # publishes after the first observe. So 3 alerts → 3
  192. # messages (one per key).
  193. /tmp/loadgen-ws-m65 --target "$WS_INGEST" --api-key "$SRC_ACME" \
  194. --count 3 --rate 5 2>&1 | tail -1
  195. sleep "$sleep_secs"
  196. count=$(curl -sS $FAKETGMD/admin/sent 2>/dev/null | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("count",0))' 2>/dev/null)
  197. if [[ "$count" == "3" ]]; then
  198. pass "faketgmd count = 3 (3 unique keys → 3 deliveries, no collapse)"
  199. else
  200. fail "faketgmd count = $count (expected 3)"
  201. fi
  202. # ── Summary ─────────────────────────────────────────────────
  203. echo ""
  204. if [[ $fails -eq 0 ]]; then
  205. echo "🟢 M6.5 smoke PASS — all checks green"
  206. exit 0
  207. else
  208. echo "🔴 M6.5 smoke FAIL — $fails check(s) failed"
  209. exit $fails
  210. fi