#!/usr/bin/env node 'use strict'; const fs = require('fs'), path = require('path'), http = require('http'); const { secret } = require('../runtime/config'); const { Monitor } = require('./monitor'); const root = path.resolve(process.env.SENTINEL_DATA || path.join(process.env.DATA || 'data', 'sentinel')); fs.mkdirSync(root, { recursive: true }); const token = secret('SCRAPER_TOKEN'); const host = process.env.SENTINEL_HOST || '127.0.0.1', port = +(process.env.SENTINEL_PORT || 8092); if (!['127.0.0.1', 'localhost', '::1'].includes(host) && !token) throw new Error('SCRAPER_TOKEN is required for non-loopback Sentinel'); const reader = process.env.READER_URL || 'http://127.0.0.1:8080'; const scraper = process.env.SCRAPER_URL || 'http://127.0.0.1:8091'; const file = path.join(root, 'state.json'); let saved = {}; if (fs.existsSync(file)) saved = JSON.parse(fs.readFileSync(file, 'utf8')); const monitor = new Monitor({ incidents: saved.incidents || [] }); const request = async (base, endpoint, body) => { const response = await fetch(base + endpoint, { headers: { ...(token ? { authorization: `Bearer ${token}` } : {}), 'content-type': 'application/json' }, signal: AbortSignal.timeout(8000), ...(body ? { method: 'POST', body: JSON.stringify(body) } : {}) }); if (!response.ok) throw new Error(`Service HTTP ${response.status}`); return response.json(); }; let stopped = false, timer, lastTick = Date.now(); async function tick() { try { const results = await Promise.allSettled([request(reader, '/health'), request(scraper, '/api/state')]); const state = await monitor.observe(results[0].status === 'fulfilled' ? results[0].value : null, results[1].status === 'fulfilled' ? results[1].value : null, input => request(scraper, '/api/recovery', input)); fs.writeFileSync(file + '.tmp', JSON.stringify(state, null, 2) + '\n'); fs.renameSync(file + '.tmp', file); lastTick = Date.now(); for (const incident of state.incidents.filter(i => i.state === 'open' && !i.logged)) { console.log(JSON.stringify({ ...incident, targetService: incident.service, service: 'sentinel', level: 'warning' })); incident.logged = true; } } catch (error) { console.error('[sentinel]', error.message); } if (!stopped) timer = setTimeout(tick, 30000); } const server = http.createServer((req, res) => { const endpoint = new URL(req.url, 'http://localhost').pathname; let code = 200, value; if (endpoint === '/health') { code = Date.now() - lastTick < 120000 ? 200 : 503; value = { ok: code === 200, service: 'sentinel', checkedAt: monitor.state.checkedAt }; } else if (token && req.headers.authorization !== `Bearer ${token}`) { code = 401; value = { error: 'Unauthorized' }; } else if (endpoint === '/api/state' && req.method === 'GET') value = monitor.state; else if (endpoint === '/metrics' && req.method === 'GET') { res.writeHead(200, { 'content-type': 'text/plain; version=0.0.4' }); return res.end(`mangacircus_reader_up ${Number(!!monitor.state.services.reader)}\nmangacircus_scraper_up ${Number(!!monitor.state.services.scraper)}\nmangacircus_open_incidents ${monitor.incidents.filter(i => i.state === 'open').length}\n`); } else { code = 404; value = { error: 'Not found' }; } res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value)); }); server.listen(port, host, () => console.log(JSON.stringify({ service: 'sentinel', event: 'started', port }))); tick(); for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => { stopped = true; clearTimeout(timer); server.close(() => process.exit(0)); });