Jelajahi Sumber

M2(1/3): migrations 003 + seed_m2 + seed runner picks both seeds

- migrations/003_subscriptions_groups.{up,down}.sql: 5 new tables
  (sources, groups, group_members, subscriptions, routing_rules)
  with FKs to companies / individuals. Idempotent CREATE
  statements. UNIQUE on (company_id, name) for routing_rules so
  the seed ON CONFLICT works.
- migrations/seed_m2.sql: 2 more individuals (Bob SRE,
  Carol NOC), 1 group (sre: Alice + Bob), 1 source row for
  acme-001:prom-prod, 3 subscriptions, 1 routing rule. All
  idempotent.
- cmd/seed/main.go: now applies seed.sql then seed_m2.sql in
  lexical order. Each is independently idempotent so re-running
  the seed container is safe.
Luis Rosales 2 bulan lalu
induk
melakukan
a29c2d8de3

+ 12 - 7
cmd/seed/main.go

@@ -44,14 +44,19 @@ func main() {
 	if err := applyDir(ctx, pool, dir, "*.up.sql"); err != nil {
 		die("apply migrations: " + err.Error())
 	}
-	seed := filepath.Join(dir, "seed.sql")
-	if _, err := os.Stat(seed); err == nil {
-		if err := execFile(ctx, pool, seed); err != nil {
-			die("seed: " + err.Error())
+	// Seed files run in lexical order, so seed.sql → seed_m2.sql
+	// → seed_m3.sql → … Each is idempotent (ON CONFLICT DO NOTHING)
+	// and safe to re-run.
+	for _, name := range []string{"seed.sql", "seed_m2.sql"} {
+		p := filepath.Join(dir, name)
+		if _, err := os.Stat(p); err != nil {
+			fmt.Fprintln(os.Stderr, "no", name, "in", dir, "(skipped)")
+			continue
 		}
-		fmt.Fprintln(os.Stderr, "seed applied:", seed)
-	} else {
-		fmt.Fprintln(os.Stderr, "no seed.sql in", dir)
+		if err := execFile(ctx, pool, p); err != nil {
+			die(name+": " + err.Error())
+		}
+		fmt.Fprintln(os.Stderr, "applied:", name)
 	}
 	fmt.Fprintln(os.Stderr, "ok")
 }

+ 7 - 0
migrations/003_subscriptions_groups.down.sql

@@ -0,0 +1,7 @@
+-- 003_subscriptions_groups.down.sql
+-- Symmetric drop. Order matters: children first.
+DROP TABLE IF EXISTS routing_rules;
+DROP TABLE IF EXISTS subscriptions;
+DROP TABLE IF EXISTS group_members;
+DROP TABLE IF EXISTS groups;
+DROP TABLE IF EXISTS sources;

+ 139 - 0
migrations/003_subscriptions_groups.up.sql

@@ -0,0 +1,139 @@
+-- 003_subscriptions_groups.up.sql
+-- M2 recipient resolution surface. See SPEC §4 (entities),
+-- SPEC §6 (recipient resolution algorithm).
+--
+-- What lands in M2:
+--   sources        — the on-tenant source address book (M1 had it
+--                    only as an env var for HMAC auth)
+--   groups         — named groups of individuals (e.g. "sre",
+--                    "noc", "on-call-rotation-A")
+--   group_members  — many-to-many individuals ↔ groups
+--   subscriptions  — per-individual opt-in to a source, with
+--                    severity gate, channel mask, quiet hours
+--   routing_rules  — per-company override: if alert matches
+--                    match_expr, route to target_type+target_id
+--                    instead of (or in addition to) source defaults
+--
+-- What stays out of M2:
+--   per-source HMAC secret in DB. Ingestd still reads
+--   BA_INGESTD_SOURCES env. The `sources` table here holds
+--   addressing metadata only.
+--   Routing rule match_expr is intentionally simple: keys are
+--   category, severity, data.<key>=<value>, all=true. Full
+--   JSONPath/expression language is M6 or later.
+
+-- ── sources ───────────────────────────────────────────────────────
+-- One row per (company, source_id). Pairs with ingestd's
+-- BA_INGESTD_SOURCES env (auth) — M2 just needs the row to exist
+-- for the resolver to know about it.
+CREATE TABLE IF NOT EXISTS sources (
+    id                   TEXT NOT NULL,                 -- sources are scoped to a company
+    company_id           TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
+    name                 TEXT NOT NULL,
+    type                 TEXT NOT NULL DEFAULT 'http',  -- http | mqtt | ws | grpc
+    rate_limit_per_sec   INTEGER NOT NULL DEFAULT 100,
+    allowed_targets      JSONB NOT NULL DEFAULT '[]'::jsonb,  -- e.g. [{"type":"group","id":"sre"}, {"type":"individual","id":"ind-..."}]
+    match_expr           JSONB NOT NULL DEFAULT '{}'::jsonb,  -- default match (category, severity, data keys)
+    status               TEXT NOT NULL DEFAULT 'active',      -- active | suspended
+    created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (company_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_sources_company ON sources(company_id) WHERE status = 'active';
+
+-- ── groups ────────────────────────────────────────────────────────
+CREATE TABLE IF NOT EXISTS groups (
+    id            TEXT NOT NULL,
+    company_id    TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
+    name          TEXT NOT NULL,
+    description   TEXT,
+    status        TEXT NOT NULL DEFAULT 'active',  -- active | archived
+    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
+    PRIMARY KEY (company_id, id)
+);
+CREATE INDEX IF NOT EXISTS idx_groups_company ON groups(company_id) WHERE status = 'active';
+
+-- ── group_members ─────────────────────────────────────────────────
+-- Junction: which individuals belong to which groups. M2 only adds
+-- rows; M3+ lets the admin UI manage them.
+CREATE TABLE IF NOT EXISTS group_members (
+    group_id      TEXT NOT NULL,
+    company_id    TEXT NOT NULL,
+    individual_id TEXT NOT NULL REFERENCES individuals(id) ON DELETE CASCADE,
+    added_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+    added_by      TEXT,
+    PRIMARY KEY (company_id, group_id, individual_id),
+    FOREIGN KEY (company_id, group_id) REFERENCES groups(company_id, id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_group_members_individual ON group_members(individual_id);
+
+-- ── subscriptions ─────────────────────────────────────────────────
+-- Per-(individual, source) opt-in with severity gate, channel mask,
+-- and per-subscriber quiet hours. See SPEC §6.
+--
+-- channel_mask is a JSON array of channel names, e.g.
+--   ["fcm", "telegram"]
+-- M2 ships with only 'fcm' actually delivered (M3 brings telegram).
+-- The resolver will include 'telegram' in the result, but no worker
+-- consumes it yet; the alert gets dropped at the broker level with
+-- a "no worker" log. See SPEC §6 / M2 honest flag in PROMPT.md.
+--
+-- quiet_hours_start / quiet_hours_end are times of day in the
+-- subscriber's tz (HH:MM). Wrap-around (e.g. 22:00–06:00) is
+-- supported by the resolver.
+--
+-- min_severity is the lowest severity that gets through. Order is
+-- info < warning < critical < inminent_colapse. NULL = no filter
+-- (any severity).
+CREATE TABLE IF NOT EXISTS subscriptions (
+    id                   BIGSERIAL PRIMARY KEY,
+    individual_id        TEXT NOT NULL REFERENCES individuals(id) ON DELETE CASCADE,
+    company_id           TEXT NOT NULL,                  -- denormalized for indexing
+    source_id            TEXT NOT NULL,
+    min_severity         TEXT,                            -- info|warning|critical|inminent_colapse; NULL = any
+    channel_mask         JSONB NOT NULL DEFAULT '["fcm"]'::jsonb,
+    quiet_hours_start    TIME,                            -- in `tz`; NULL = no quiet hours
+    quiet_hours_end      TIME,
+    tz                   TEXT NOT NULL DEFAULT 'UTC',
+    status               TEXT NOT NULL DEFAULT 'active',  -- active | paused
+    created_at           TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (individual_id, source_id),
+    FOREIGN KEY (company_id, source_id) REFERENCES sources(company_id, id) ON DELETE CASCADE
+);
+CREATE INDEX IF NOT EXISTS idx_subscriptions_source ON subscriptions(company_id, source_id) WHERE status = 'active';
+CREATE INDEX IF NOT EXISTS idx_subscriptions_individual ON subscriptions(individual_id) WHERE status = 'active';
+
+-- ── routing_rules ─────────────────────────────────────────────────
+-- Optional per-company overrides. A rule with priority=N is
+-- evaluated in priority order; first match wins, unless
+-- `continue` is true. The M2 resolver evaluates them in a single
+-- SQL pass (window function) and unions the targets.
+--
+-- match_expr shape (M2):
+--   {
+--     "category": "storage",             // optional, exact match
+--     "severity": "critical",            // optional, exact match
+--     "data": {"host": "db-prod-03"},    // optional, all keys must match
+--     "all": true                        // if true, match every alert for this company
+--   }
+--
+-- target shape:
+--   { "type": "group",       "id": "sre"   }
+--   { "type": "individual",  "id": "ind-…" }
+--   { "type": "broadcast",   "id": null    }  -- all active individuals in company
+--   { "type": "drop",        "id": null    }  -- explicitly drop the alert for this company
+--
+-- enabled=false → skipped. Priorities are 0..N, lower is higher
+-- priority. Rules within the same priority are unioned.
+CREATE TABLE IF NOT EXISTS routing_rules (
+    id            BIGSERIAL PRIMARY KEY,
+    company_id    TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
+    name          TEXT NOT NULL,
+    priority      INTEGER NOT NULL DEFAULT 100,
+    match_expr    JSONB NOT NULL DEFAULT '{}'::jsonb,
+    target        JSONB NOT NULL,                       -- {type, id} as above
+    enabled       BOOLEAN NOT NULL DEFAULT TRUE,
+    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
+    UNIQUE (company_id, name)
+);
+CREATE INDEX IF NOT EXISTS idx_routing_rules_company_priority
+    ON routing_rules(company_id, priority) WHERE enabled = TRUE;

+ 90 - 0
migrations/seed_m2.sql

@@ -0,0 +1,90 @@
+-- seed_m2.sql
+-- M2 seed. Adds 2 more individuals (Bob, Carol), one group (sre:
+-- Alice+Bob), the M2 source row, and four subscriptions covering
+-- the four M2 verification scenarios.
+--
+-- Idempotent (ON CONFLICT DO NOTHING + careful ordering). Safe
+-- to re-run on top of seed.sql.
+
+-- ── individuals ───────────────────────────────────────────────────
+INSERT INTO individuals (id, company_id, full_name, email, locale, tz) VALUES
+    ('ind-acme-002', 'acme-001', 'Bob SRE',       'bob@acme.example',   'en', 'UTC'),
+    ('ind-acme-003', 'acme-001', 'Carol NOC',     'carol@acme.example', 'en', 'UTC')
+ON CONFLICT (id) DO NOTHING;
+
+-- ── tokens ────────────────────────────────────────────────────────
+INSERT INTO fcm_tokens (individual_id, token, device_id, locale, app_version) VALUES
+    ('ind-acme-002', 'fake-fcm-token-acme-bob-001',   'pixel-7-bob',   'en', '1.0.0'),
+    ('ind-acme-003', 'fake-fcm-token-acme-carol-001', 'pixel-7-carol', 'en', '1.0.0')
+ON CONFLICT (token) DO NOTHING;
+
+-- ── source row ────────────────────────────────────────────────────
+-- Pairs with BA_INGESTD_SOURCES env (HMAC auth). The env
+-- registers the secret; this row gives the resolver addressing
+-- metadata (allowed_targets + match_expr).
+--
+-- For M2 we leave allowed_targets EMPTY and rely on the
+-- broadcast fallback. Wait — per your Q2 answer, we hard-fail.
+-- So we populate allowed_targets to a real target so the
+-- resolver has somewhere to route.
+INSERT INTO sources (id, company_id, name, type, rate_limit_per_sec, allowed_targets, match_expr) VALUES
+    ('prom-prod', 'acme-001', 'Prometheus (prod)', 'http', 100,
+     '[
+        {"type":"group",      "id":"sre"},
+        {"type":"individual", "id":"ind-acme-003"}
+     ]'::jsonb,
+     '{}'::jsonb
+    )
+ON CONFLICT (company_id, id) DO NOTHING;
+
+-- ── groups ────────────────────────────────────────────────────────
+INSERT INTO groups (id, company_id, name, description) VALUES
+    ('sre', 'acme-001', 'SRE Team', 'Site Reliability Engineering on-call')
+ON CONFLICT (company_id, id) DO NOTHING;
+
+-- ── group members ─────────────────────────────────────────────────
+-- sre = Alice (ind-acme-001) + Bob (ind-acme-002)
+INSERT INTO group_members (company_id, group_id, individual_id) VALUES
+    ('acme-001', 'sre', 'ind-acme-001'),
+    ('acme-001', 'sre', 'ind-acme-002')
+ON CONFLICT (company_id, group_id, individual_id) DO NOTHING;
+
+-- ── subscriptions ─────────────────────────────────────────────────
+-- Scenarios for M2_VERIFICATION.md:
+--
+-- (a) ind-acme-001 (Alice, in sre): plain fcm subscription, no
+--     severity filter, no quiet hours. Gets every alert.
+--
+-- (b) ind-acme-002 (Bob, in sre): fcm subscription, min_severity
+--     = 'critical'. Only critical / inminent_colapse alerts.
+--
+-- (c) ind-acme-003 (Carol, NOT in sre): fcm subscription with
+--     quiet_hours 00:00–23:59 in UTC. Skips everything except
+--     inminent_colapse (which bypasses quiet hours).
+--
+-- (d) ind-acme-002 (Bob) also subscribes via a routing_rule to
+--     demonstrate rule-driven targeting: any storage alert with
+--     host=db-prod-03 goes to him.
+INSERT INTO subscriptions (individual_id, company_id, source_id, min_severity, channel_mask, quiet_hours_start, quiet_hours_end, tz) VALUES
+    -- (a) Alice: everything
+    ('ind-acme-001', 'acme-001', 'prom-prod', NULL,    '["fcm"]'::jsonb, NULL,    NULL,    'UTC'),
+    -- (b) Bob: critical and above
+    ('ind-acme-002', 'acme-001', 'prom-prod', 'critical', '["fcm"]'::jsonb, NULL, NULL,    'UTC'),
+    -- (c) Carol: quiet hours cover the whole day
+    ('ind-acme-003', 'acme-001', 'prom-prod', NULL,    '["fcm"]'::jsonb, '00:00', '23:59', 'UTC')
+ON CONFLICT (individual_id, source_id) DO NOTHING;
+
+-- ── routing rules ─────────────────────────────────────────────────
+-- Rule (d): any storage alert with data.host = db-prod-03 goes
+-- to Bob (ind-acme-002). Note: Bob's subscription has
+-- min_severity=critical, so this rule only fires for critical
+-- storage alerts about db-prod-03 (which is the realistic case
+-- for a "run this migration now" pager). The rule's match_expr
+-- intentionally does NOT filter on severity so we can show that
+-- the resolver still applies subscriptions on top of rules.
+INSERT INTO routing_rules (company_id, name, priority, match_expr, target) VALUES
+    ('acme-001', 'DB prod-03 storage page', 10,
+     '{"category":"storage","data":{"host":"db-prod-03"}}'::jsonb,
+     '{"type":"individual","id":"ind-acme-002"}'::jsonb
+    )
+ON CONFLICT (company_id, name) DO NOTHING;