From 47411e0b8605c78d690421f24b26f8fc4cdfee29 Mon Sep 17 00:00:00 2001 From: ndk Date: Wed, 4 Mar 2026 14:07:44 +0100 Subject: [PATCH] Add Dockerfile and convert Grafana-GitHub bridge to standard Node.js MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert from Cloudflare Worker to a plain Node.js HTTP server so it can be deployed to any container infrastructure. No external dependencies — uses only Node.js stdlib (node:http). Adds Dockerfile, health endpoint, smoke tests, and deployment docs. Addresses paritytech/devops#5019 feedback on PR #3224. --- .../grafana-github-bridge/.gitignore | 1 + .../grafana-github-bridge/Dockerfile | 12 + .../grafana-github-bridge/README.md | 49 +++ .../grafana-github-bridge/package.json | 13 + .../grafana-github-bridge/src/index.js | 309 ++++++++++++++++++ .../grafana-github-bridge/test.js | 90 +++++ 6 files changed, 474 insertions(+) create mode 100644 deployments/local-scripts/grafana-github-bridge/.gitignore create mode 100644 deployments/local-scripts/grafana-github-bridge/Dockerfile create mode 100644 deployments/local-scripts/grafana-github-bridge/README.md create mode 100644 deployments/local-scripts/grafana-github-bridge/package.json create mode 100644 deployments/local-scripts/grafana-github-bridge/src/index.js create mode 100644 deployments/local-scripts/grafana-github-bridge/test.js diff --git a/deployments/local-scripts/grafana-github-bridge/.gitignore b/deployments/local-scripts/grafana-github-bridge/.gitignore new file mode 100644 index 0000000000..c2658d7d1b --- /dev/null +++ b/deployments/local-scripts/grafana-github-bridge/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/deployments/local-scripts/grafana-github-bridge/Dockerfile b/deployments/local-scripts/grafana-github-bridge/Dockerfile new file mode 100644 index 0000000000..75bd96d278 --- /dev/null +++ b/deployments/local-scripts/grafana-github-bridge/Dockerfile @@ -0,0 +1,12 @@ +FROM node:22-alpine + +WORKDIR /app + +COPY package.json ./ +# No dependencies to install — stdlib only + +COPY src/ ./src/ + +EXPOSE 3000 + +CMD ["node", "src/index.js"] diff --git a/deployments/local-scripts/grafana-github-bridge/README.md b/deployments/local-scripts/grafana-github-bridge/README.md new file mode 100644 index 0000000000..aa6a45be4d --- /dev/null +++ b/deployments/local-scripts/grafana-github-bridge/README.md @@ -0,0 +1,49 @@ +# Grafana → GitHub Alert Bridge + +Receives Grafana Alertmanager webhook POSTs and creates categorized GitHub issues in `paritytech/parity-bridges-common`. + +## Quick start + +```bash +# Run directly +GITHUB_TOKEN=ghp_xxx node src/index.js + +# Or with Docker +docker build -t grafana-github-bridge . +docker run -p 3000:3000 \ + -e GITHUB_TOKEN=ghp_xxx \ + -e WEBHOOK_SECRET=optional-secret \ + grafana-github-bridge +``` + +## Environment variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GITHUB_TOKEN` | Yes | GitHub PAT with `issues:write` scope | +| `WEBHOOK_SECRET` | No | Shared secret — if set, requests must include `Authorization: Bearer ` | +| `PORT` | No | Listen port (default: `3000`) | + +## Endpoints + +- `POST /` — Grafana webhook receiver +- `GET /health` — Health check (returns `{"status":"ok"}`) + +## Grafana configuration + +Add a contact point of type **Webhook** with: +- **URL**: `http://:3000/` +- **HTTP Method**: `POST` +- If `WEBHOOK_SECRET` is set, add header: `Authorization: Bearer ` + +## Testing + +```bash +GITHUB_TOKEN=fake PORT=9876 node test.js +``` + +## Alert classification + +Alerts are classified by their `alertname` label into categories: `relay-down`, `version-guard`, `headers-mismatch`, `finality-lag`, `delivery-lag`, `confirmation-lag`, `reward-lag`, `low-balance`, or `other`. + +Critical alerts and unclassified alerts get the `claude-escalate` label; others get the `claude` label. diff --git a/deployments/local-scripts/grafana-github-bridge/package.json b/deployments/local-scripts/grafana-github-bridge/package.json new file mode 100644 index 0000000000..f103619e95 --- /dev/null +++ b/deployments/local-scripts/grafana-github-bridge/package.json @@ -0,0 +1,13 @@ +{ + "name": "grafana-github-bridge", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "start": "node src/index.js", + "test": "node test.js" + }, + "engines": { + "node": ">=18" + } +} diff --git a/deployments/local-scripts/grafana-github-bridge/src/index.js b/deployments/local-scripts/grafana-github-bridge/src/index.js new file mode 100644 index 0000000000..6d0da5db5f --- /dev/null +++ b/deployments/local-scripts/grafana-github-bridge/src/index.js @@ -0,0 +1,309 @@ +/** + * Grafana → GitHub Issue bridge. + * + * Receives Grafana Alertmanager webhook POSTs and creates GitHub issues + * with the "alert" label, categorised by bridge alert type. + * + * Environment variables: + * GITHUB_TOKEN – GitHub PAT with `issues:write` scope + * WEBHOOK_SECRET – (optional) shared secret for request validation + * PORT – HTTP listen port (default 3000) + */ + +import http from 'node:http'; + +const REPO = 'paritytech/parity-bridges-common'; + +// --------------------------------------------------------------------------- +// Alert classification +// --------------------------------------------------------------------------- + +const ALERT_CATEGORIES = [ + { + id: 'relay-down', + label: 'relay-down', + match: (t) => /node is down/i.test(t), + emoji: '🔴', + action: 'Check relay pod status and restart if needed.', + }, + { + id: 'version-guard', + label: 'version-guard', + match: (t) => /version guard|abort/i.test(t), + emoji: '⛔', + action: + 'A chain was upgraded — redeploy the relay with the new runtime.', + }, + { + id: 'headers-mismatch', + label: 'headers-mismatch', + match: (t) => /headers? mismatch|different.?forks/i.test(t), + emoji: '🔀', + action: + 'Source chain forked — the relay may need to re-sync headers from the canonical fork.', + }, + { + id: 'finality-lag', + label: 'finality-lag', + match: (t) => /finality.*lag|sync.*lag/i.test(t), + emoji: '⏳', + action: + 'Finality headers are not advancing — check relay logs and source chain finality.', + }, + { + id: 'delivery-lag', + label: 'delivery-lag', + match: (t) => /delivery.*lag/i.test(t), + emoji: '📦', + action: + 'Messages generated but not delivered — check message relay process.', + }, + { + id: 'confirmation-lag', + label: 'confirmation-lag', + match: (t) => /confirmation.*lag/i.test(t), + emoji: '✅', + action: + 'Messages delivered but not confirmed back to source — check confirmation relay.', + }, + { + id: 'reward-lag', + label: 'reward-lag', + match: (t) => /reward.*lag/i.test(t), + emoji: '💰', + action: + 'Confirmations not being rewarded — check reward mechanism and relay balance.', + }, + { + id: 'low-balance', + label: 'low-balance', + match: (t) => /balance/i.test(t), + emoji: '💸', + action: 'Relay account balance is low — top up the account.', + }, +]; + +function classify(alertname) { + for (const cat of ALERT_CATEGORIES) { + if (cat.match(alertname)) return cat; + } + return { + id: 'other', + label: 'bridge-alert', + emoji: '⚠️', + action: null, + }; +} + +// Extract environment (prod vs testnet) from labels or title +function detectEnv(alert) { + const domain = alert.labels?.domain || ''; + const title = alert.labels?.alertname || ''; + if (domain === 'parity-testnet' || /rococo|westend/i.test(title)) + return 'testnet'; + if (domain === 'parity-chains' || /polkadot|kusama/i.test(title)) + return 'production'; + return 'unknown'; +} + +// Extract the bridge pair from the alert title, e.g. "Polkadot <> Kusama" +function detectBridgePair(alert) { + const title = alert.labels?.alertname || ''; + const m = title.match( + /(\w+?)(?:BridgeHub)?\s*(?:->|<>|to)\s*(\w+?)(?:BridgeHub)?[\s_]/i, + ); + if (m) return `${m[1]} ↔ ${m[2]}`; + return alert.labels?.bridge || null; +} + +// --------------------------------------------------------------------------- +// Issue formatting +// --------------------------------------------------------------------------- + +function formatTitle(alert, category) { + const alertname = alert.labels?.alertname || 'Unknown alert'; + return `${category.emoji} [Alert] ${alertname}`; +} + +function formatBody(alert, payload, category, env, bridgePair) { + const labels = alert.labels || {}; + const annotations = alert.annotations || {}; + const values = alert.values || {}; + + const lines = [ + `## ${category.emoji} ${labels.alertname || 'Alert'}`, + '', + `| Field | Value |`, + `|-------|-------|`, + `| **Status** | \`${alert.status}\` |`, + `| **Severity** | \`${labels.severity || 'unknown'}\` |`, + `| **Category** | \`${category.id}\` |`, + `| **Environment** | \`${env}\` |`, + bridgePair ? `| **Bridge** | \`${bridgePair}\` |` : null, + `| **Started** | ${alert.startsAt || 'N/A'} |`, + '', + ]; + + if (annotations.summary) { + lines.push(`### Summary`, '', annotations.summary, ''); + } + if (annotations.description) { + lines.push(`### Description`, '', annotations.description, ''); + } + + if (category.action) { + lines.push(`### Suggested Action`, '', `> ${category.action}`, ''); + } + + if (Object.keys(values).length > 0) { + lines.push('### Metric Values', ''); + for (const [key, val] of Object.entries(values)) { + lines.push(`- **${key}:** \`${val}\``); + } + lines.push(''); + } + + // Links + const linkLines = []; + if (alert.generatorURL) linkLines.push(`- [Alert rule](${alert.generatorURL})`); + if (payload.externalURL) linkLines.push(`- [Grafana](${payload.externalURL})`); + if (annotations.__dashboardUid__) { + const base = payload.externalURL || 'https://grafana.teleport.parity.io'; + const dashUrl = `${base}/d/${annotations.__dashboardUid__}`; + linkLines.push(`- [Dashboard](${dashUrl})`); + } + if (linkLines.length) { + lines.push('### Links', '', ...linkLines, ''); + } + + // All labels + lines.push( + '
All labels', + '', + '```json', + JSON.stringify(labels, null, 2), + '```', + '', + '
', + '', + '
Raw alert payload', + '', + '```json', + JSON.stringify(alert, null, 2), + '```', + '', + '
', + ); + + return lines.filter((l) => l !== null).join('\n'); +} + +// --------------------------------------------------------------------------- +// HTTP handler +// --------------------------------------------------------------------------- + +async function handleRequest(req, res) { + if (req.method === 'GET' && req.url === '/health') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ status: 'ok' })); + return; + } + + if (req.method !== 'POST') { + res.writeHead(405, { 'Content-Type': 'text/plain' }); + res.end('Method not allowed'); + return; + } + + const webhookSecret = process.env.WEBHOOK_SECRET; + if (webhookSecret) { + const auth = req.headers['authorization']; + if (auth !== `Bearer ${webhookSecret}`) { + res.writeHead(401, { 'Content-Type': 'text/plain' }); + res.end('Unauthorized'); + return; + } + } + + let body = ''; + for await (const chunk of req) { + body += chunk; + } + + let payload; + try { + payload = JSON.parse(body); + } catch { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Invalid JSON'); + return; + } + + const alerts = payload.alerts || []; + const results = []; + + for (const alert of alerts) { + if (alert.status !== 'firing') continue; + + const alertname = alert.labels?.alertname || 'Unknown alert'; + const category = classify(alertname); + const envName = detectEnv(alert); + const bridgePair = detectBridgePair(alert); + + const title = formatTitle(alert, category); + const issueBody = formatBody(alert, payload, category, envName, bridgePair); + + const severity = alert.labels?.severity || 'warning'; + const ghLabels = ['alert', category.label]; + if (envName === 'testnet') ghLabels.push('testnet'); + if (envName === 'production') ghLabels.push('production'); + + // Tiered model: critical/unknown → Sonnet (escalate), others → Haiku (triage) + if (severity === 'critical' || category.id === 'other') { + ghLabels.push('claude-escalate'); + } else { + ghLabels.push('claude'); + } + + const ghToken = process.env.GITHUB_TOKEN; + const resp = await fetch( + `https://api.github.com/repos/${REPO}/issues`, + { + method: 'POST', + headers: { + Authorization: `Bearer ${ghToken}`, + Accept: 'application/vnd.github+json', + 'User-Agent': 'grafana-github-bridge', + }, + body: JSON.stringify({ + title, + body: issueBody, + labels: ghLabels, + assignees: [], + }), + }, + ); + + const respBody = resp.status === 201 ? await resp.json() : null; + results.push({ + alertname, + category: category.id, + env: envName, + status: resp.status, + issue: respBody?.html_url || null, + }); + } + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ processed: results.length, results })); +} + +// --------------------------------------------------------------------------- +// Server +// --------------------------------------------------------------------------- + +const PORT = parseInt(process.env.PORT || '3000', 10); +const server = http.createServer(handleRequest); +server.listen(PORT, () => { + console.log(`grafana-github-bridge listening on port ${PORT}`); +}); diff --git a/deployments/local-scripts/grafana-github-bridge/test.js b/deployments/local-scripts/grafana-github-bridge/test.js new file mode 100644 index 0000000000..28f3bfe458 --- /dev/null +++ b/deployments/local-scripts/grafana-github-bridge/test.js @@ -0,0 +1,90 @@ +/** + * Smoke test — starts the server, sends a fake Grafana webhook, verifies response. + * Does NOT create a real GitHub issue (would need GITHUB_TOKEN). + */ + +import http from 'node:http'; +import { spawn } from 'node:child_process'; + +const PORT = 9876; + +const fakePayload = { + receiver: 'webhook', + status: 'firing', + alerts: [ + { + status: 'firing', + labels: { + alertname: 'Polkadot -> KusamaBridgeHub finality sync lag', + severity: 'warning', + domain: 'parity-chains', + }, + annotations: { + summary: 'Finality headers are lagging behind.', + }, + startsAt: '2025-01-01T00:00:00Z', + values: { lag: '42' }, + }, + { + status: 'resolved', + labels: { alertname: 'should-be-skipped' }, + }, + ], + externalURL: 'https://grafana.example.com', +}; + +async function run() { + const server = spawn('node', ['src/index.js'], { + env: { ...process.env, PORT: String(PORT), GITHUB_TOKEN: 'fake-token' }, + stdio: ['pipe', 'pipe', 'inherit'], + }); + + // Wait for server to start + await new Promise((resolve) => { + server.stdout.on('data', (data) => { + if (data.toString().includes('listening')) resolve(); + }); + }); + + try { + // Test health endpoint + const healthRes = await fetch(`http://localhost:${PORT}/health`); + console.assert(healthRes.status === 200, 'Health check should return 200'); + const healthBody = await healthRes.json(); + console.assert(healthBody.status === 'ok', 'Health body should be {status: "ok"}'); + console.log('✓ Health endpoint OK'); + + // Test 405 for GET on / + const getRes = await fetch(`http://localhost:${PORT}/`); + console.assert(getRes.status === 405, 'GET / should return 405'); + console.log('✓ GET / returns 405'); + + // Test webhook (will fail at GitHub API call, but we verify classification works) + const res = await fetch(`http://localhost:${PORT}/`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(fakePayload), + }); + + const body = await res.json(); + console.log('Response:', JSON.stringify(body, null, 2)); + + // Should have processed 1 alert (the resolved one is skipped) + console.assert(body.processed === 1, `Expected 1 processed, got ${body.processed}`); + + const result = body.results[0]; + console.assert(result.category === 'finality-lag', `Expected finality-lag, got ${result.category}`); + console.assert(result.env === 'production', `Expected production, got ${result.env}`); + // GitHub API call will fail with fake token — that's expected + console.assert(result.status !== 201, 'Should not have created a real issue with fake token'); + + console.log('✓ All tests passed'); + } finally { + server.kill(); + } +} + +run().catch((err) => { + console.error('Test failed:', err); + process.exit(1); +});