| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- #!/usr/bin/env bash
- # Live M5 smoke test. Run from repo root:
- # bash scripts/m5_smoke.sh
- #
- # Walks through the 6 scenarios in M5_VERIFICATION.md:
- #
- # Step 2 — happy path: 1 alert via WS → 2 deliveries (Alice fcm + telegram)
- # Step 3 — 5 alerts, mixed severity + 30% dedupe → 10 deliveries
- # Step 4 — bad API key (correct format, wrong secret) → 0 deliveries,
- # ws_messages_total{result="bad_signature"} ticks
- # Step 5 — per-IP concurrency cap (35 conns, cap=32) → 32 ok + 3 rejected,
- # connection_rejected_total{transport="ws"} = 3
- # Step 6 — live tail: subscribe then send → 1 frame arrives,
- # tail_subscribers = 1 while connected
- # Step 7 — live tail company filter: subscribe to globex-002 only →
- # acme alerts filtered out, globex alert passes
- #
- # The script assumes the loadgen-ws binary is built at /tmp/loadgen-ws
- # (run `cd loadgen && go build -o /tmp/loadgen-ws ./cmd/ws`). The
- # per-IP cap test binary and tail driver are auto-built on first run;
- # they live in /tmp and are reused on subsequent runs.
- #
- # Exit code is the number of failed checks.
- set -e
- cd "$(dirname "$0")/.."
- PG="docker exec -i broad-announce-postgres-1 psql -U ba -d ba -A -t"
- INGESTD_METRICS=http://localhost:8800/metrics
- SRC_ACME=acme-001:prom-prod:s3cret-acme
- SRC_GLOBEX=globex-002:grafana:s3cret-globex
- WS_INGEST=ws://localhost:8800/v1/ingest/ws
- WS_TAIL=ws://localhost:8800/v1/tail/ws
- TAIL_TOKEN=tail-dev-token-please-change-in-prod
- fails=0
- pass() { echo " ✅ $*"; }
- fail() { echo " ❌ $*"; fails=$((fails+1)); }
- reset_state() {
- $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
- $PG -c "UPDATE individuals SET telegram_chat_id = NULL, telegram_user_id = NULL WHERE id = 'ind-acme-003';" >/dev/null
- $PG -c "UPDATE subscriptions SET min_severity = 'critical' WHERE individual_id = 'ind-acme-002' AND source_id = 'prom-prod';" >/dev/null
- $PG -c "TRUNCATE deliveries;" >/dev/null
- curl -sS -X POST http://localhost:8830/admin/reset >/dev/null
- docker compose restart telegramd >/dev/null
- for i in 1 2 3 4 5 6 7 8 9 10; do
- if curl -sS http://localhost:8822/health 2>/dev/null | grep -q '"status":"ok"'; then
- sleep 1
- break
- fi
- sleep 1
- done
- }
- ws_counter() {
- # $1 = result label
- curl -sS "$INGESTD_METRICS" 2>/dev/null | \
- grep -E "^ba_ingestd_ws_messages_total\{result=\"$1\"" | \
- awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
- }
- ws_conn() {
- # $1 = state label
- curl -sS "$INGESTD_METRICS" 2>/dev/null | \
- grep -E "^ba_ingestd_ws_connections_total\{state=\"$1\"" | \
- awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
- }
- ws_rej() {
- curl -sS "$INGESTD_METRICS" 2>/dev/null | \
- grep -E '^ba_ingestd_connection_rejected_total\{[^}]*transport="ws"[^}]*\}' | \
- awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
- }
- tail_gauge() {
- curl -sS "$INGESTD_METRICS" 2>/dev/null | \
- grep -E '^ba_ingestd_tail_subscribers\{' | \
- awk '{print $NF}' | awk -F. '{print $1+0; exit}' | head -1
- }
- # ── Setup: build loadgen-ws + per-IP-cap test binary + tail driver ──
- mkdir -p /tmp/m5_smoke
- if [[ ! -x /tmp/loadgen-ws ]]; then
- echo "▸ Building /tmp/loadgen-ws"
- (cd loadgen && CGO_ENABLED=0 go build -o /tmp/loadgen-ws ./cmd/ws)
- fi
- if [[ ! -x /tmp/m5-tail-test ]]; then
- echo "▸ Building /tmp/m5-tail-test"
- CGO_ENABLED=0 go build -o /tmp/m5-tail-test ./loadgen/cmd/m5drivers/tail
- fi
- if [[ ! -x /tmp/m5-perip-test ]]; then
- echo "▸ Building /tmp/m5-perip-test"
- cat > /tmp/m5_smoke/perip_test.go <<'GO'
- package main
- import (
- "flag"
- "fmt"
- "sync"
- "sync/atomic"
- "git3.techno-world.net/lrosales/broad-announce/internal/wsclient"
- )
- func main() {
- wsURL := flag.String("ws", "ws://localhost:8800/v1/ingest/ws", "ws endpoint")
- apiKey := flag.String("key", "acme-001:prom-prod:s3cret-acme", "api key")
- cap := flag.Int("cap", 35, "number of concurrent WS conns to attempt (cap=32 should reject 3)")
- flag.Parse()
- var ok, rejected atomic.Int64
- var wg sync.WaitGroup
- for i := 0; i < *cap; i++ {
- wg.Add(1)
- go func() {
- defer wg.Done()
- c, err := wsclient.Connect(wsclient.Config{URL: *wsURL, APIKey: *apiKey, DialTimeout: 5e9})
- if err != nil {
- rejected.Add(1)
- return
- }
- ok.Add(1)
- defer c.Close()
- }()
- }
- wg.Wait()
- fmt.Printf("ok=%d rejected=%d (cap=32 → ok<=32, rejected>=3 if cap=35)\n", ok.Load(), rejected.Load())
- }
- GO
- mkdir -p ./scripts/m5_smoke_tmp
- cp /tmp/m5_smoke/perip_test.go ./scripts/m5_smoke_tmp/main.go
- CGO_ENABLED=0 go build -o /tmp/m5-perip-test ./scripts/m5_smoke_tmp
- rm -rf ./scripts/m5_smoke_tmp
- fi
- # ─────────────────────────────────────────────────────────────────
- echo "── M5 smoke — WebSocket ingest + live tail + per-IP cap ──"
- echo ""
- reset_state
- # ── Step 2: happy path ────────────────────────────────────────
- echo "── Step 2: 1 alert via WS → 2 deliveries (Alice fcm + telegram) ──"
- acme_recv_before=$(ws_counter received)
- acme_acc_before=$(ws_counter accepted)
- deliveries_before=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
- acme_recv_before=${acme_recv_before:-0}
- acme_acc_before=${acme_acc_before:-0}
- deliveries_before=${deliveries_before:-0}
- /tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 1 --rate 1 2>&1 | tail -3
- sleep 2
- acme_recv_after=$(ws_counter received)
- acme_acc_after=$(ws_counter accepted)
- deliveries_after=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
- acme_recv_after=${acme_recv_after:-0}
- acme_acc_after=${acme_acc_after:-0}
- deliveries_after=${deliveries_after:-0}
- recv_delta=$((acme_recv_after - acme_recv_before))
- acc_delta=$((acme_acc_after - acme_acc_before))
- del_delta=$((deliveries_after - deliveries_before))
- if [[ $recv_delta -ge 1 ]]; then
- pass "ws_messages_total{result=\"received\"} +$recv_delta"
- else
- fail "ws_messages_total{result=\"received\"} delta was $recv_delta (expected ≥1)"
- fi
- if [[ $acc_delta -ge 1 ]]; then
- pass "ws_messages_total{result=\"accepted\"} +$acc_delta"
- else
- fail "ws_messages_total{result=\"accepted\"} delta was $acc_delta (expected ≥1)"
- fi
- if [[ $del_delta -ge 2 ]]; then
- pass "deliveries +$del_delta (Alice fcm + telegram)"
- else
- fail "deliveries delta was $del_delta (expected ≥2)"
- fi
- # ── Step 3: 5 alerts, mixed severity + 30% dedupe ───────────
- echo ""
- echo "── Step 3: 5 alerts, mixed severity + 30% dedupe → 10 deliveries ──"
- reset_state
- acme_recv_before=$(ws_counter received)
- acme_acc_before=$(ws_counter accepted)
- deliveries_before=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
- # Send 5 alerts at a steady rate. Default profile is
- # normal which uses 30% dedupe (i.e. ~2 of 5 alerts share
- # a dedupe_key), so we expect 3 new accepts and 2 dupes.
- /tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 5 --rate 5 2>&1 | tail -3
- sleep 3
- acme_recv_after=$(ws_counter received)
- acme_acc_after=$(ws_counter accepted)
- deliveries_after=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
- recv_delta=$((acme_recv_after - acme_recv_before))
- acc_delta=$((acme_acc_after - acme_acc_before))
- del_delta=$((deliveries_after - deliveries_before))
- if [[ $recv_delta -eq 5 ]]; then
- pass "ws_messages_total{result=\"received\"} +$recv_delta"
- else
- fail "ws_messages_total{result=\"received\"} delta was $recv_delta (expected 5)"
- fi
- if [[ $acc_delta -ge 3 ]]; then
- pass "ws_messages_total{result=\"accepted\"} +$acc_delta (≥3 of 5 are new; rest deduped)"
- else
- fail "ws_messages_total{result=\"accepted\"} delta was $acc_delta (expected ≥3)"
- fi
- if [[ $del_delta -ge 8 ]]; then
- pass "deliveries +$del_delta (≥1 per non-deduped alert × 2 channels)"
- else
- fail "deliveries delta was $del_delta (expected ≥8)"
- fi
- # ── Step 4: bad API key ─────────────────────────────────────
- echo ""
- echo "── Step 4: bad API key (wrong secret) → 0 deliveries, bad_signature counter ticks ──"
- reset_state
- bad_before=$(ws_counter bad_signature)
- deliveries_before=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
- # Use the right company:source (so auth passes) but wrong HMAC.
- cat > /tmp/m5_smoke/badkey.go <<'GO'
- package main
- import (
- "crypto/hmac"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "fmt"
- "time"
- "github.com/gorilla/websocket"
- )
- func main() {
- conn, _, err := websocket.DefaultDialer.Dial("ws://localhost:8800/v1/ingest/ws", nil)
- if err != nil { fmt.Println("dial err:", err); return }
- defer conn.Close()
- type Auth struct {
- APIKey string `json:"api_key"`
- }
- if err := conn.WriteJSON(Auth{APIKey: "acme-001:prom-prod:s3cret-acme"}); err != nil {
- fmt.Println("auth err:", err); return
- }
- conn.SetReadDeadline(time.Now().Add(2*time.Second))
- _, authReply, err := conn.ReadMessage()
- if err != nil { fmt.Println("auth reply err:", err); return }
- fmt.Println("auth reply:", string(authReply))
- body := map[string]any{"company_id":"acme-001","source_id":"prom-prod","severity":"info","title":"smoke-badkey","labels":map[string]string{"instance":"smoke-1"}}
- bb, _ := json.Marshal(body)
- // Sign with a wrong key — should fail HMAC verify.
- mac := hmac.New(sha256.New, []byte("WRONG-SECRET"))
- mac.Write(bb)
- hexMac := hex.EncodeToString(mac.Sum(nil))
- sig := "t=1700000000,v1=" + hexMac
- env := map[string]any{
- "alert": json.RawMessage(bb),
- "auth": sig,
- }
- if err := conn.WriteJSON(env); err != nil { fmt.Println("write err:", err); return }
- conn.SetReadDeadline(time.Now().Add(2*time.Second))
- _, ack2, err := conn.ReadMessage()
- if err != nil { fmt.Println("ack2 err:", err); return }
- fmt.Println("ack2:", string(ack2))
- }
- GO
- mkdir -p ./scripts/m5_smoke_tmp
- cp /tmp/m5_smoke/badkey.go ./scripts/m5_smoke_tmp/main.go
- CGO_ENABLED=0 go build -o /tmp/m5-badkey ./scripts/m5_smoke_tmp
- rm -rf ./scripts/m5_smoke_tmp
- /tmp/m5-badkey 2>&1 | tail -5
- sleep 2
- bad_after=$(ws_counter bad_signature)
- deliveries_after=$($PG -c "SELECT count(*) FROM deliveries WHERE channel IN ('fcm','telegram');" | tr -d ' \n')
- bad_delta=$((bad_after - bad_before))
- del_delta=$((deliveries_after - deliveries_before))
- if [[ $bad_delta -ge 1 ]]; then
- pass "ws_messages_total{result=\"bad_signature\"} +$bad_delta"
- else
- fail "ws_messages_total{result=\"bad_signature\"} delta was $bad_delta (expected ≥1)"
- fi
- if [[ $del_delta -eq 0 ]]; then
- pass "no deliveries created"
- else
- fail "deliveries delta was $del_delta (expected 0)"
- fi
- # ── Step 5: per-IP concurrency cap ─────────────────────────
- echo ""
- echo "── Step 5: per-IP cap (35 conns, cap=32) → 32 ok + 3 rejected ──"
- # Drain any residual per-IP count from prior tests. The per-IP
- # counter decrements on TCP close; some kernels buffer the
- # FIN/ACK and we can race the next test. A 5-second wait is
- # enough for the janitor to forget any stragglers (idle > 1s
- # in the test) and for the kernel to reap closed sockets.
- sleep 5
- rej_before=$(ws_rej)
- perip_out=$(/tmp/m5-perip-test --cap 35 2>&1 | tail -1)
- echo " perip_test: $perip_out"
- sleep 1
- rej_after=$(ws_rej)
- rej_delta=$((rej_after - rej_before))
- if echo "$perip_out" | grep -qE "ok=3[0-2] rejected=[2-3]"; then
- pass "perip_test reported 30-32 ok, 2-3 rejected (cap=32 held)"
- else
- fail "perip_test output unexpected: $perip_out"
- fi
- if [[ $rej_delta -ge 2 && $rej_delta -le 3 ]]; then
- pass "connection_rejected_total{transport=\"ws\"} +$rej_delta"
- else
- fail "connection_rejected_total{transport=\"ws\"} delta was $rej_delta (expected 2-3)"
- fi
- sleep 1
- # ── Step 6: live tail, no filter ───────────────────────────
- echo ""
- echo "── Step 6: live tail (no filter) — subscribe then send 1 alert → 1 frame arrives ──"
- tail_before=$(tail_gauge)
- /tmp/m5-tail-test --target "$WS_TAIL" --token "$TAIL_TOKEN" --duration 6s 2>&1 > /tmp/m5_tail1.log &
- TAIL_PID=$!
- sleep 2 # let the tail subscribe
- tail_mid=$(tail_gauge)
- if [[ $tail_mid -ge 1 ]]; then
- pass "tail_subscribers = $tail_mid while connected"
- else
- fail "tail_subscribers was $tail_mid (expected ≥1)"
- fi
- /tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 1 --rate 1 2>&1 | tail -1
- wait $TAIL_PID 2>/dev/null
- if grep -q "^FRAME:" /tmp/m5_tail1.log; then
- pass "tail received at least 1 frame"
- else
- fail "tail received 0 frames (log: $(cat /tmp/m5_tail1.log))"
- fi
- tail_after=$(tail_gauge)
- if [[ $tail_after -eq 0 ]]; then
- pass "tail_subscribers back to 0 after disconnect"
- else
- fail "tail_subscribers was $tail_after after disconnect (expected 0)"
- fi
- # ── Step 7: live tail, company filter ─────────────────────
- echo ""
- echo "── Step 7: live tail (company=globex-002) — acme filtered, globex passes ──"
- /tmp/m5-tail-test --target "$WS_TAIL" --token "$TAIL_TOKEN" --company globex-002 --duration 6s 2>&1 > /tmp/m5_tail2.log &
- TAIL_PID=$!
- sleep 2
- # Send acme — should NOT reach the tail
- /tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_ACME" --count 1 --rate 1 2>&1 | tail -1
- sleep 1
- # Send globex — should reach the tail
- /tmp/loadgen-ws --target "$WS_INGEST" --api-key "$SRC_GLOBEX" --count 1 --rate 1 2>&1 | tail -1
- wait $TAIL_PID 2>/dev/null
- # Count frames per company
- acme_frames=$(grep -c "company_id\":\"acme-001" /tmp/m5_tail2.log || true)
- globex_frames=$(grep -c "company_id\":\"globex-002" /tmp/m5_tail2.log || true)
- if [[ $acme_frames -eq 0 ]]; then
- pass "0 acme frames reached globex-only tail (filter works)"
- else
- fail "got $acme_frames acme frames on globex-only tail (expected 0)"
- fi
- if [[ $globex_frames -ge 1 ]]; then
- pass "$globex_frames globex frame(s) reached the tail"
- else
- fail "got $globex_frames globex frames (expected ≥1)"
- fi
- # ── Summary ─────────────────────────────────────────────────
- echo ""
- if [[ $fails -eq 0 ]]; then
- echo "🟢 M5 smoke PASS — all checks green"
- exit 0
- else
- echo "🔴 M5 smoke FAIL — $fails check(s) failed"
- exit $fails
- fi
|