ops(sched-pipelines): локальный стенд sched + ym/ozon воркеры + browser/ntfy/alert-bridge; ТЗ разосланы
This commit is contained in:
126
host-stacks/local/sched-pipelines/bridge/index.cjs
Normal file
126
host-stacks/local/sched-pipelines/bridge/index.cjs
Normal file
@@ -0,0 +1,126 @@
|
||||
'use strict';
|
||||
/**
|
||||
* alert-bridge — приёмник sched webhook-алертов (универсальный outbound webhook).
|
||||
* Форматирует пейлоад sched → ntfy + email (Unisender Go, go2).
|
||||
*
|
||||
* sched шлёт: POST {version, event, task, run|schedule}, подпись
|
||||
* X-Sched-Signature-256: sha256=<hex> (HMAC-SHA256, GitHub-модель) если WEBHOOK_SECRET задан.
|
||||
*
|
||||
* Env: NTFY_URL (http://ntfy:8096), NTFY_TOPIC, WEBHOOK_SECRET,
|
||||
* UNISENDER_API_KEY, UNISENDER_FROM, EMAIL_TO, PORT (9090)
|
||||
*/
|
||||
const http = require('node:http');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const {
|
||||
NTFY_URL = 'http://ntfy',
|
||||
NTFY_TOPIC = 'sched-alerts',
|
||||
WEBHOOK_SECRET = '',
|
||||
UNISENDER_API_KEY = '',
|
||||
UNISENDER_FROM = '',
|
||||
EMAIL_TO = '',
|
||||
PORT = '9090',
|
||||
} = process.env;
|
||||
|
||||
const UNISENDER_ENDPOINT = 'https://go2.unisender.ru/ru/transactional/api/v1/email/send.json';
|
||||
|
||||
function verify(req, raw) {
|
||||
if (!WEBHOOK_SECRET) return true;
|
||||
const sig = req.headers['x-sched-signature-256'] || '';
|
||||
const expected = 'sha256=' + crypto.createHmac('sha256', WEBHOOK_SECRET).update(raw).digest('hex');
|
||||
if (sig.length !== expected.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
|
||||
}
|
||||
|
||||
function fmt(payload) {
|
||||
const task = payload.task ?? {};
|
||||
const name = task.name ?? '?';
|
||||
if (payload.event === 'missed-slot') {
|
||||
const s = payload.schedule ?? {};
|
||||
return {
|
||||
title: `⏰ missed-slot: ${name}`,
|
||||
body: `Задача ${name} не успела на слот ${s.scheduledAt ?? '?'} (задержка ${s.delayMs ?? '?'} ms).`,
|
||||
prio: 4,
|
||||
alert: true,
|
||||
};
|
||||
}
|
||||
const status = (payload.event ?? '').replace(/^run\./, '') || '?';
|
||||
const run = payload.run ?? {};
|
||||
const err = run.error ? `\nОшибка: ${run.error}` : '';
|
||||
const failed = status === 'failed';
|
||||
return {
|
||||
title: `${failed ? '🚨' : 'ℹ️'} run.${status}: ${name}`,
|
||||
body: `Рана ${(run.id ?? '?').slice(0, 12)} · статус ${status} · attempt ${run.attempt ?? 1}${err}`,
|
||||
prio: failed ? 5 : 3,
|
||||
alert: failed,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendNtfy(m) {
|
||||
const base = NTFY_URL.replace(/\/+$/, '');
|
||||
const res = await fetch(`${base}/${encodeURIComponent(NTFY_TOPIC)}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
topic: NTFY_TOPIC,
|
||||
title: m.title,
|
||||
message: m.body,
|
||||
priority: m.prio,
|
||||
tags: [m.prio >= 4 ? 'rotating_light' : 'info'],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`ntfy ${res.status}`);
|
||||
}
|
||||
|
||||
async function sendEmail(m) {
|
||||
if (!UNISENDER_API_KEY || !EMAIL_TO) return { skipped: 'no UNISENDER_API_KEY/EMAIL_TO' };
|
||||
const res = await fetch(UNISENDER_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
api_key: UNISENDER_API_KEY,
|
||||
email: EMAIL_TO,
|
||||
sender_name: 'sched-pipeline',
|
||||
sender_email: UNISENDER_FROM,
|
||||
subject: m.title,
|
||||
body: m.body,
|
||||
}),
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`unisender ${res.status}: ${text.slice(0, 200)}`);
|
||||
return text;
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const json = (code, body) => {
|
||||
const payload = JSON.stringify(body);
|
||||
res.writeHead(code, { 'content-type': 'application/json' });
|
||||
res.end(payload);
|
||||
};
|
||||
if (req.method !== 'POST' || req.url !== '/webhook') return json(404, { ok: false, error: 'not found' });
|
||||
|
||||
const chunks = [];
|
||||
for await (const c of req) chunks.push(c);
|
||||
const raw = Buffer.concat(chunks);
|
||||
|
||||
if (!verify(req, raw)) return json(401, { ok: false, error: 'bad signature' });
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(raw.toString('utf8'));
|
||||
} catch {
|
||||
return json(400, { ok: false, error: 'bad json' });
|
||||
}
|
||||
|
||||
const m = fmt(payload);
|
||||
const errs = [];
|
||||
try { await sendNtfy(m); } catch (e) { errs.push(`ntfy: ${e.message}`); }
|
||||
if (m.alert) {
|
||||
try { await sendEmail(m); } catch (e) { errs.push(`email: ${e.message}`); }
|
||||
}
|
||||
json(200, { ok: true, errors: errs });
|
||||
});
|
||||
|
||||
server.listen(Number(PORT), '0.0.0.0', () => {
|
||||
console.log(`alert-bridge on :${PORT} (ntfy=${NTFY_URL}/${NTFY_TOPIC}, email=${EMAIL_TO ? 'on' : 'off'})`);
|
||||
});
|
||||
Reference in New Issue
Block a user