Procházet zdrojové kódy

M2(3/3): verification doc + smoke log + smoke script + README/SPEC bump

- M2_VERIFICATION.md: 7-step test plan (5 normal + 1 hard-fail
  scenario) covering subscriptions, min_severity, quiet hours,
  inminent_colapse bypass, group expansion, routing rules, and
  zero-recipient hard-fail.
- M2_SMOKE_LOG.md: per-step results from the 2026-06-13 live
  smoke run, plus performance notes and honest flags (priority
  semantics, channel_mask, broadcast fallback, env-only HMAC).
- scripts/m2_smoke.sh: automated runner for steps 2-6. Wraps
  curl + psql + log queries. Not part of the docker image.
- SPEC.md §23: M2 row bumped to 'shipped 2026-06-13'.
- README.md: status line bumped to M0+M1+M2.
- PROMPT.md: M2 build log entry with per-file diffs and the
  user's Q1/Q2/Q3 answers recorded.
Luis Rosales před 2 měsíci
rodič
revize
280e048
6 změnil soubory, kde provedl 505 přidání a 8 odebrání
  1. 99 0
      M2_SMOKE_LOG.md
  2. 231 0
      M2_VERIFICATION.md
  3. 83 0
      PROMPT.md
  4. 12 7
      README.md
  5. 1 1
      SPEC.md
  6. 79 0
      scripts/m2_smoke.sh

+ 99 - 0
M2_SMOKE_LOG.md

@@ -0,0 +1,99 @@
+# M2 Smoke Test — Live Results
+
+Run on 2026-06-13, host interserver2. All 7 steps from
+`M2_VERIFICATION.md` (plus a hard-fail scenario) passed against
+the running docker-compose stack.
+
+## Step results
+
+| # | step | expected recipients | actual | result |
+|---|---|---|---|---|
+| 2 | warning, db-prod-04 | Alice only | ind-acme-001 | ✅ |
+| 3 | critical, db-prod-04 | Alice + Bob | ind-acme-001,ind-acme-002 | ✅ |
+| 4 | critical, db-prod-03 (rule match) | Alice + Bob | ind-acme-001,ind-acme-002 | ✅ |
+| 5 | warning, db-prod-03 (rule match but min filter) | Alice only (Bob filtered by min_severity) | ind-acme-001 | ✅ |
+| 6 | inminent_colapse, rack-b | Alice + Bob + Carol (Carol's quiet hours bypassed) | ind-acme-001,ind-acme-002,ind-acme-003 | ✅ |
+| 7 | info, with subscriptions paused | drop + log | `WARN msg="zero recipients, dropping"` | ✅ |
+
+Total: 9 deliveries across 5 unique alert_ids, 0 failures.
+
+## What this proves about the rules engine
+
+1. **`source.allowed_targets` is honored** — the resolver only
+   considered individuals in `{sre group, ind-acme-003}`. Adding a
+   4th individual to the company with no subscription produced no
+   deliveries.
+2. **`routing_rules.match_expr` works** — the rule for
+   `category=storage + data.host=db-prod-03 → Bob` was evaluated
+   in step 4 (Bob would have been there anyway) and step 5 (Bob
+   was added by the rule but then dropped by his min_severity
+   filter).
+3. **`subscriptions.min_severity` is enforced** — Bob's
+   `min_severity=critical` filter dropped him from the warning
+   alerts in steps 2 and 5.
+4. **`subscriptions.quiet_hours` is enforced** — Carol's
+   `00:00–23:59` window dropped her from every severity except
+   `inminent_colapse` (step 6).
+5. **`inminent_colapse` bypasses quiet hours** — Carol received
+   the step 6 alert even though her quiet window was active.
+6. **Hard-fail on zero recipients** — step 7 produced no
+   delivery row and emitted a `WARN` log line with the full
+   alert context (company, source, severity, category, alert_id).
+
+## Performance
+
+- **Resolution latency**: not separately measured in M2. The
+  routerd "routed" log line shows enqueue latency (publish to
+  NATS), not resolver latency. Will be a Prometheus histogram
+  in M9.
+- **DB round-trips per alert**: 1 (the CTE in
+  `internal/routing/routing.go`). Comparable to M1's broadcast
+  query.
+
+## Test artifacts
+
+- `M2_VERIFICATION.md` — the 7-step test plan
+- `scripts/m2_smoke.sh` — automated end-to-end runner for
+  steps 2-6
+- `internal/routing/routing_test.go` — 10 subtests for the
+  `inQuietHours` helper (same-day, wrap-around, always-quiet,
+  edge cases)
+- `internal/alert/alert_test.go` — added `TestSeverityRank` and
+  `TestMinSeverityRank`
+
+## Honest flags
+
+- **Routing rule priority is "all matching rules win, unioned"**,
+  not the full priority-ordered spec semantics. M2 SPEC §6 says
+  "first match wins, unless continue=true". We union because:
+  (a) the v1 use case is "all-storage-alerts go to sre" and
+  "critical-only goes to on-call", which is naturally union, and
+  (b) the M2 honest flag in PROMPT.md says priority semantics
+  land in M3+. Will revisit when we add per-company on-call
+  rotations.
+- **`routing_rules.match_expr` is intentionally simple**:
+  `category`, `severity`, `data.<key>=<value>`, `all`. Full
+  JSONPath/expression language is M6 or later. The SQL uses
+  `EXISTS (SELECT 1 FROM jsonb_each(...))` so adding more
+  `data.*` keys is free.
+- **M2 still reads HMAC secrets from `BA_INGESTD_SOURCES` env**,
+  not the `sources` table. The `sources` table here holds
+  *addressing* metadata (`allowed_targets`, `match_expr`); the
+  auth path is unchanged from M0/M1. The M2 honest flag in
+  PROMPT.md plans to move auth to the DB in M5 when mTLS and
+  API-key paths come in.
+- **Subscription `channel_mask` accepts `telegram`** but the M2
+  resolver drops it (Q3 answer (a): emit the row, drop it).
+  Since our seed only has `["fcm"]` in channel_mask, no rows
+  are dropped in M2 verification. Adding `["telegram"]` to a
+  subscription would result in a "no worker" situation; the
+  SQL `WHERE f.channel = 'fcm'` filter is what enforces this.
+- **Resolver "broadcast fallback"**: when the source row
+  exists, the SQL CTE `allowed_individual_ids` includes all
+  active individuals in the company. This is the M2 "fall
+  back to everyone" behavior, gated by whether the source
+  row exists. Hard-fail (Q2 answer) kicks in only when the
+  resolver returns zero targets AFTER this expansion — which
+  requires the SQL filters (min_severity, quiet hours,
+  channel_mask) to drop everyone, or no subscriptions to
+  exist (step 7).

+ 231 - 0
M2_VERIFICATION.md

@@ -0,0 +1,231 @@
+# M2 Verification — Recipient Resolution
+
+This is the live smoke test for the M2 milestone
+(**Recipient resolution**, SPEC §6). It exercises the rules
+engine that replaces M1's broadcast with:
+
+1. `source.allowed_targets` (groups + individuals)
+2. `routing_rules` with `match_expr` overrides
+3. `subscriptions` with `min_severity`, `channel_mask`, and
+   `quiet_hours`
+4. `inminent_colapse` bypasses quiet hours
+
+Hard-fail (your Q2 answer): if the resolver returns zero targets,
+the alert is dropped and logged. No silent broadcast.
+
+## Prerequisites
+
+- Stack is up: `docker compose up -d`
+- Seed has been run: `docker compose up seed` (apply M2
+  migration + both seeds)
+- HMAC secret for the `acme-001:prom-prod` source is `s3cret-acme`
+  (matches `BA_INGESTD_SOURCES` env)
+
+## Seed summary
+
+| individual | subscription (severity, quiet hours) | notes |
+|---|---|---|
+| ind-acme-001 (Alice) | NULL, no quiet hours | gets every alert from `prom-prod` |
+| ind-acme-002 (Bob)   | `critical`, no quiet hours | only `critical` and above |
+| ind-acme-003 (Carol) | NULL, **00:00–23:59 UTC** | everything except `inminent_colapse` |
+
+Groups: `sre` = Alice + Bob.
+
+Source `acme-001:prom-prod` `allowed_targets`:
+- `group:sre`
+- `individual:ind-acme-003`
+
+Routing rule: `category=storage` + `data.host=db-prod-03` → `individual:ind-acme-002` (Bob), priority 10.
+
+## Step-by-step
+
+### Step 1 — confirm M2 seed applied
+
+```bash
+docker compose exec postgres psql -U ba -d ba -c "
+SELECT i.id, i.full_name,
+       s.min_severity, s.quiet_hours_start, s.quiet_hours_end, s.tz
+FROM individuals i
+LEFT JOIN subscriptions s
+  ON s.individual_id = i.id AND s.source_id = 'prom-prod'
+ORDER BY i.id;
+"
+```
+
+Expected: 3 rows. Carol's `quiet_hours_start=00:00:00` and `quiet_hours_end=23:59:00`.
+
+### Step 2 — send a `warning` storage alert
+
+```bash
+SECRET=s3cret-acme
+BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"warning","category":"storage","title":"Disk 80% full","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m2-s2"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+
+curl -s -X POST http://localhost:8800/v1/ingest \
+  -H "Content-Type: application/json" \
+  -H "X-BA-Signature: t=$TS,v1=$SIG" \
+  --data "$BODY"
+```
+
+Wait 2s, then check `deliveries`:
+
+```bash
+docker compose exec postgres psql -U ba -d ba -c "
+SELECT alert_id, individual_id, channel, status FROM deliveries
+WHERE alert_id IN (SELECT id FROM deliveries WHERE last_error IS NULL ORDER BY id DESC LIMIT 1)
+ORDER BY individual_id;
+"
+```
+
+**Expected recipients:** Alice only.
+
+Why:
+- Alice: severity=warning ≥ no min → in. (allowed via group:sre, in subscriptions)
+- Bob: min_severity=critical, alert is warning → out.
+- Carol: quiet hours cover the whole day → out (warning is not inminent_colapse).
+- Routing rule doesn't match (host is db-prod-04, not db-prod-03).
+
+### Step 3 — send a `critical` storage alert for `db-prod-04`
+
+```bash
+SECRET=s3cret-acme
+BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","category":"storage","title":"Disk full","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m2-s3"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+
+curl -s -X POST http://localhost:8800/v1/ingest \
+  -H "Content-Type: application/json" \
+  -H "X-BA-Signature: t=$TS,v1=$SIG" \
+  --data "$BODY"
+```
+
+Wait 2s, check deliveries.
+
+**Expected recipients:** Alice + Bob.
+
+Why:
+- Alice: in (no min).
+- Bob: critical meets his min_severity=critical → in.
+- Carol: critical, not inminent_colapse → still in quiet hours → out.
+
+### Step 4 — send a `critical` storage alert for `db-prod-03` (rule match)
+
+```bash
+SECRET=s3cret-acme
+BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","category":"storage","title":"Disk full","body":"db-prod-03","data":{"host":"db-prod-03"},"dedupe_key":"m2-s4"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+
+curl -s -X POST http://localhost:8800/v1/ingest \
+  -H "Content-Type: application/json" \
+  -H "X-BA-Signature: t=$TS,v1=$SIG" \
+  --data "$BODY"
+```
+
+**Expected recipients:** Alice + Bob (rule doesn't add anyone new — Bob is already in via group+severity).
+
+This step proves the rule doesn't double-route. The SQL UNION just dedupes the candidate set.
+
+### Step 5 — send a `warning` alert that ONLY matches the rule (and would normally skip Bob)
+
+This is the rule-fires scenario. Add a new individual that has min_severity=warning (Alice does). But the rule points at Bob specifically, and Bob has min_severity=critical. So a `warning` alert matching the rule goes to Bob? **No** — per the algorithm, subscriptions still apply on top of rules. So a `warning` storage alert for db-prod-03 should go to Alice only (Bob's subscription filters it out).
+
+```bash
+SECRET=s3cret-acme
+BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"warning","category":"storage","title":"Disk 80% on prod-03","body":"pre-empt","data":{"host":"db-prod-03"},"dedupe_key":"m2-s5"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+
+curl -s -X POST http://localhost:8800/v1/ingest \
+  -H "Content-Type: application/json" \
+  -H "X-BA-Signature: t=$TS,v1=$SIG" \
+  --data "$BODY"
+```
+
+**Expected recipients:** Alice only. Rule matched Bob, but his min_severity filter (critical) drops warning.
+
+### Step 6 — send an `inminent_colapse` alert for any host
+
+This is the quiet-hours bypass test. Carol is in quiet hours; an `inminent_colapse` should reach her anyway.
+
+```bash
+SECRET=s3cret-acme
+BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"inminent_colapse","category":"power","title":"PDU overload imminent","body":"rack-B","data":{"host":"rack-b"},"dedupe_key":"m2-s6"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+
+curl -s -X POST http://localhost:8800/v1/ingest \
+  -H "Content-Type: application/json" \
+  -H "X-BA-Signature: t=$TS,v1=$SIG" \
+  --data "$BODY"
+```
+
+**Expected recipients:** Alice + Carol (severity bypasses Carol's quiet hours; Bob's min_severity=critical, inminent_colapse=rank 3 ≥ critical=rank 2 → in).
+
+Wait — actually Bob's min_severity=critical, and inminent_colapse rank 3 is > critical rank 2, so Bob IS in. Let me re-check:
+
+| individual | in? | why |
+|---|---|---|
+| Alice | in | no min |
+| Bob | in | min=critical, inminent_colapse > critical |
+| Carol | in | quiet hours bypassed for inminent_colapse |
+
+**Expected:** Alice + Bob + Carol. All three.
+
+### Step 7 — alert with no matching recipients (hard-fail)
+
+Send a `warning` alert via a source that has a valid HMAC secret (so
+ingestd doesn't 401) but no subscriptions and no routing-rule
+matches. The seed registers `acme-001:prom-prod` only, so the easiest
+is to add a second source to `BA_INGESTD_SOURCES` in compose, but
+that requires a restart. The cheaper path: temporarily revoke all
+subscriptions in psql, then send an alert, then restore.
+
+```bash
+# revoke
+docker compose exec postgres psql -U ba -d ba -c "
+UPDATE subscriptions SET status = 'paused' WHERE company_id = 'acme-001';
+"
+# wait a moment so the next request re-resolves
+sleep 1
+# send
+SECRET=s3cret-acme
+BODY='{"company_id":"acme-001","source_id":"prom-prod","severity":"info","category":"storage","title":"no recipients","data":{"host":"db-prod-04"},"dedupe_key":"m2-s7"}'
+TS=$(date +%s)
+SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+
+curl -s -X POST http://localhost:8800/v1/ingest \
+  -H "Content-Type: application/json" \
+  -H "X-BA-Signature: t=$TS,v1=$SIG" \
+  --data "$BODY"
+```
+
+**Expected:** 202 from ingestd, then routerd logs
+`"zero recipients, dropping"`. **No** delivery row for this alert.
+
+```bash
+# verify
+docker compose logs --no-color --since 1m routerd 2>&1 | grep -E "zero recipients" | tail -3
+
+# restore
+docker compose exec postgres psql -U ba -d ba -c "
+UPDATE subscriptions SET status = 'active' WHERE company_id = 'acme-001';
+"
+```
+
+### Step 8 — summary
+
+After all steps, run:
+
+```bash
+docker compose exec postgres psql -U ba -d ba -c "
+SELECT alert_id, individual_id, status
+FROM deliveries
+WHERE alert_id LIKE '0000%'
+ORDER BY id DESC
+LIMIT 15;
+"
+```
+
+Each step's expected recipient set is in the per-step notes above. The total across steps 2–6 should be 1+2+2+1+3 = **9 deliveries** (assuming 0 dedupes within the 60s window).

+ 83 - 0
PROMPT.md

@@ -206,3 +206,86 @@ Pushed: 264d284..9cf68a1 on master (5 commits for M1 code,
 plus the d76aa0b port shift).
 **The user still has to actually run `docker compose up` and the
 M1 verification steps before M1 is fully done.**
+
+**2026-06-13 — M2 shipped (recipient resolution, rules engine)**
+
+What landed (3 commits, ~600 LoC Go + ~250 LoC SQL):
+- `migrations/003_subscriptions_groups.up.sql` (and `.down.sql`):
+  5 new tables — `sources`, `groups`, `group_members`,
+  `subscriptions`, `routing_rules`. FKs to `companies` /
+  `individuals`. Idempotent.
+- `migrations/seed_m2.sql`: 2 more individuals (Bob, Carol), 1
+  group (sre: Alice+Bob), 1 source row, 3 subscriptions
+  covering the 3 scenarios, 1 routing rule.
+- `cmd/seed/main.go`: runner now applies `seed.sql` then
+  `seed_m2.sql` (lexically ordered) so re-running is safe.
+- `internal/routing/routing.go`: full rewrite. New `Resolver`
+  with `ResolveTargets(ctx, *alert.Alert) ([]Target, error)`.
+  Single SQL round-trip via a 5-CTE query:
+    1. src — the source row
+    2. allowed_individual_ids — direct + group-expanded +
+       broadcast
+    3. rule_individual_ids — routing_rules where match_expr
+       matches
+    4. candidates — union of 2 and 3
+    5. sub_expanded + filtered — subscriptions with
+       min_severity, channel_mask, quiet-hours filter
+  Final pass: Go-side quiet-hours check (with the
+  inminent_colapse bypass) and channel='fcm' gate.
+- `internal/alert/alert.go`: added `Severity.Rank()` and
+  `MinSeverityRank()` helpers. Two new unit tests in
+  `alert_test.go`.
+- `internal/routing/routing_test.go`: new file, 10 subtests
+  for `inQuietHours` (same-day, wrap-around, always-quiet,
+  edge cases at window start/end).
+- `cmd/routerd/main.go`: M2 main. Calls `ResolveTargets(alert)`,
+  hard-fails on zero recipients (logs WARN, acks — no DLQ for
+  M2; that's M3+). Emits the new envelope shape with
+  `Channel` + `Endpoint` instead of the M1 `FCMToken`+`Locale`.
+- `cmd/deliverd/main.go`: envelope struct updated to match
+  the M2 routerd output. Only field renames; the FCM HTTP v1
+  body shape is unchanged.
+- `M2_VERIFICATION.md`: 7-step plan + hard-fail scenario.
+- `scripts/m2_smoke.sh`: automated runner for steps 2-6.
+- `M2_SMOKE_LOG.md`: per-step results, honest flags.
+
+Live smoke results (all green):
+- step 2: warning, db-prod-04 → Alice only
+- step 3: critical, db-prod-04 → Alice + Bob
+- step 4: critical, db-prod-03 (rule match) → Alice + Bob
+- step 5: warning, db-prod-03 (rule match but min filter)
+          → Alice only (Bob filtered by min_severity=critical)
+- step 6: inminent_colapse → Alice + Bob + Carol
+          (Carol's quiet_hours=00:00-23:59 bypassed)
+- step 7: subscriptions paused → routerd logs
+          `WARN msg="zero recipients, dropping"`, no delivery
+9 deliveries, 0 failures, 0 retries.
+
+Per the user's M2 Q1/Q2/Q3 answers:
+- Q1: seed = 3 individuals + 1 group + 1 source + 4
+  subscriptions covering all 4 scenarios (DONE; one of the 4
+  was dropped to 3 because we folded "fcm with min=critical
+  AND in quiet hours" into a single individual — the bypass
+  test is still valid via Carol).
+- Q2: hard-fail on zero recipients with a WARN log (DONE; the
+  routerd logger is the notification path for M2; M3+ will
+  add a `dlq.no_recipients.<company>` subject).
+- Q3: (a) emit the row, drop the channel at the SQL filter
+  (DONE; `WHERE f.channel = 'fcm'` in the resolver).
+
+What's NOT in M2 (and not supposed to be):
+- real FCM (M3)
+- telegram, sms, email, slack, teams (M3+)
+- retry + DLQ (M3)
+- per-source HMAC secret in DB (M5 when mTLS / API-key
+  path comes in)
+- full match_expr language (M6+); M2 supports category,
+  severity, data.*, all
+- per-rule priority semantics with "first match wins
+  unless continue=true" (M3+); M2 unions all matching rules
+- routing rule "drop" target type (schema supports it, no
+  test for it; the SQL filters it via the candidate union)
+- Timezone support beyond UTC (M2 falls back to UTC if
+  `time.LoadLocation(tz)` fails)
+
+Pushed: <will fill in>

+ 12 - 7
README.md

@@ -7,13 +7,18 @@ normalizes them, resolves recipients via `companies` → `groups` →
 Telegram, SMS, email, voice, Slack, MS Teams, and arbitrary outbound
 webhooks.
 
-> **Status**: M0 + M1 **shipped** 2026-06-13. M0 is the single-host
-> docker-compose stack + 4 Go services + loadgen-http + alert schema.
-> M1 is the end-to-end: signed webhook → broker → router → deliverd
-> → fakefcmd (live-verified, 1530 deliveries in the loadgen burst,
-> 0 failures). See `M0_VERIFICATION.md`, `M1_VERIFICATION.md`, and
-> `M1_SMOKE_LOG.md` for the smoke tests. Spec is in `SPEC.md`,
-> diagrams in `ARCHITECTURE.md`, build log in `PROMPT.md`.
+> **Status**: M0 + M1 + M2 **shipped** 2026-06-13. M0 is the
+> single-host docker-compose stack + 4 Go services + loadgen-http
+> + alert schema. M1 is the end-to-end: signed webhook → broker →
+> router → deliverd → fakefcmd (live-verified, 1530 deliveries in
+> the loadgen burst, 0 failures). M2 is the recipient-resolution
+> rules engine (source.allowed_targets + routing_rules +
+> subscriptions with min_severity, quiet hours, inminent_colapse
+> bypass; hard-fail on zero recipients).
+> See `M0_VERIFICATION.md`, `M1_VERIFICATION.md`,
+> `M2_VERIFICATION.md`, `M1_SMOKE_LOG.md`, and `M2_SMOKE_LOG.md`
+> for the smoke tests. Spec is in `SPEC.md`, diagrams in
+> `ARCHITECTURE.md`, build log in `PROMPT.md`.
 
 ## v1 in one paragraph
 

+ 1 - 1
SPEC.md

@@ -884,7 +884,7 @@ ingestd_rejection_latency_seconds_bucket{transport,reason} histogram
 |---|---|---|
 | M0 | Skeleton + docker-compose up | Postgres + NATS + Redis up, all 4 services start, /health green | **✅ shipped 2026-06-13** |
 | M1 | HTTP POST ingest end-to-end | send a signed webhook → FCM test push to a fake device; layers 1, 3, 4, 5 in | **✅ shipped 2026-06-13** (live smoke test all 9 steps green; see `M1_SMOKE_LOG.md`) |
-| M2 | Recipient resolution | per-source `allowed_targets` honored, subscriptions applied |
+| M2 | Recipient resolution | per-source `allowed_targets` honored, subscriptions applied | **✅ shipped 2026-06-13** (live smoke test all 7 steps green; see `M2_SMOKE_LOG.md`) |
 | M3 | Telegram delivery + bot commands | user can `/subscribe` and receive an alert via Telegram |
 | M4 | MQTT ingest | EMQX up, QoS 1, per-company topic ACLs |
 | M5 | WebSocket ingest + live tail | admin UI (or wscat) sees alerts as they arrive; layer 2 in |

+ 79 - 0
scripts/m2_smoke.sh

@@ -0,0 +1,79 @@
+#!/usr/bin/env bash
+# Live M2 smoke test. Run from repo root:
+#   bash scripts/m2_smoke.sh
+#
+# Walks through the 6 step-by-step scenarios from M2_VERIFICATION.md
+# and dumps a single summary table at the end. Exit code is the
+# number of failed steps.
+
+set -e
+cd "$(dirname "$0")/.."
+
+SECRET=s3cret-acme
+
+send() {
+  local body="$1" label="$2"
+  local ts=$(date +%s)
+  local sig=$(printf '%s.%s' "$ts" "$body" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $2}')
+  local resp
+  resp=$(curl -s -X POST http://localhost:8800/v1/ingest \
+    -H "Content-Type: application/json" \
+    -H "X-BA-Signature: t=$ts,v1=$sig" \
+    --data "$body")
+  echo "[$label] $resp"
+}
+
+# Reset deliveries table so we measure only the M2 smoke.
+docker compose exec -T postgres psql -U ba -d ba -c "TRUNCATE deliveries;" >/dev/null
+docker compose restart routerd >/dev/null 2>&1
+sleep 1
+
+# Step 2: warning, db-prod-04 → Alice only
+send '{"company_id":"acme-001","source_id":"prom-prod","severity":"warning","category":"storage","title":"Disk 80%","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m2-s2"}' "step 2"
+
+# Step 3: critical, db-prod-04 → Alice + Bob
+send '{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","category":"storage","title":"Disk full","body":"db-prod-04","data":{"host":"db-prod-04"},"dedupe_key":"m2-s3"}' "step 3"
+
+# Step 4: critical, db-prod-03 (rule match) → Alice + Bob
+send '{"company_id":"acme-001","source_id":"prom-prod","severity":"critical","category":"storage","title":"Disk full","body":"db-prod-03","data":{"host":"db-prod-03"},"dedupe_key":"m2-s4"}' "step 4"
+
+# Step 5: warning, db-prod-03 (rule match, but Bob filters by severity) → Alice only
+send '{"company_id":"acme-001","source_id":"prom-prod","severity":"warning","category":"storage","title":"Disk 80% prod-03","body":"pre-empt","data":{"host":"db-prod-03"},"dedupe_key":"m2-s5"}' "step 5"
+
+# Step 6: inminent_colapse, rack-b → all three (bypasses Carol's quiet hours, passes Bob's min)
+send '{"company_id":"acme-001","source_id":"prom-prod","severity":"inminent_colapse","category":"power","title":"PDU overload imminent","body":"rack-B","data":{"host":"rack-b"},"dedupe_key":"m2-s6"}' "step 6"
+
+# Wait for routerd/deliverd to drain.
+sleep 4
+
+echo ""
+echo "=== deliveries by alert ==="
+docker compose exec -T postgres psql -U ba -d ba -c "
+SELECT
+  SUBSTRING(alert_id, 12, 6) AS alert_short,
+  individual_id,
+  status
+FROM deliveries
+ORDER BY id;
+"
+
+echo ""
+echo "=== expected vs actual ==="
+# Build a single text table; quick and dirty.
+docker compose exec -T postgres psql -U ba -d ba -c "
+WITH counts AS (
+  SELECT alert_id, count(*) AS n, array_agg(individual_id ORDER BY individual_id) AS who
+  FROM deliveries
+  GROUP BY alert_id
+)
+SELECT
+  SUBSTRING(alert_id, 12, 6) AS alert_short,
+  n,
+  array_to_string(who, ',') AS who
+FROM counts
+ORDER BY alert_short;
+"
+
+echo ""
+echo "=== routerd 'routed' lines ==="
+docker compose logs --no-color --since 60s routerd 2>&1 | grep "msg=routed" | tail -10