#!/usr/bin/env node // health-server.js // // Een minimaal HTTP-endpoint dat de daemon-state uitleest: // - /health → JSON met status, last batch, failures, token-status // - /healthz → 200 OK (minimal liveness, voor docker healthcheck) // // State komt uit ${AGENT_STATE_DIR}/state.json. Marker-files: // - UNHEALTHY → 503, te veel opeenvolgende fouten // - TOKEN_EXPIRED → 503, een credential is verlopen // // Geen externe deps — gebruikt alleen node built-ins. const http = require('node:http'); const fs = require('node:fs/promises'); const path = require('node:path'); const { execSync } = require('node:child_process'); const STATE_DIR = process.env.AGENT_STATE_DIR || '/var/run/agent'; const PORT = Number(process.env.AGENT_HEALTH_PORT || 8080); // Als de heartbeat ouder is dan dit, beschouwen we de daemon als zombie. const STALE_HEARTBEAT_SECONDS = 360; // 6 min — wrapper schrijft elke iteratie const STATE_FILE = path.join(STATE_DIR, 'state.json'); const UNHEALTHY_MARKER = path.join(STATE_DIR, 'UNHEALTHY'); const TOKEN_EXPIRED_MARKER = path.join(STATE_DIR, 'TOKEN_EXPIRED'); const CACHE_LOW_BYTES = 100 * 1024 * 1024; // 100 MB function cacheBytesFree() { const out = execSync('df -PB1 /var/cache').toString().split('\n')[1]; return parseInt(out.split(/\s+/)[3], 10); } async function exists(p) { try { await fs.access(p); return true; } catch { return false; } } async function readState() { try { const raw = await fs.readFile(STATE_FILE, 'utf8'); return JSON.parse(raw); } catch (err) { return { status: 'unknown', error: String(err) }; } } function ageSeconds(iso) { if (!iso) return null; const then = Date.parse(iso); if (Number.isNaN(then)) return null; return Math.floor((Date.now() - then) / 1000); } async function buildResponse() { const state = await readState(); const tokenExpired = await exists(TOKEN_EXPIRED_MARKER); const unhealthy = await exists(UNHEALTHY_MARKER); const heartbeatAgeS = ageSeconds(state.heartbeatAt); const heartbeatStale = heartbeatAgeS !== null && heartbeatAgeS > STALE_HEARTBEAT_SECONDS; const cacheFreeBytes = cacheBytesFree(); if (cacheFreeBytes < CACHE_LOW_BYTES) { return { httpStatus: 503, body: { status: 'unhealthy', reason: 'cache-low' }, }; } let httpStatus = 200; let effectiveStatus = state.status || 'unknown'; if (tokenExpired) { httpStatus = 503; effectiveStatus = 'token-expired'; } else if (unhealthy) { httpStatus = 503; effectiveStatus = 'unhealthy'; } else if (heartbeatStale) { httpStatus = 503; effectiveStatus = 'stale'; } const body = { status: effectiveStatus, rawStatus: state.status, startedAt: state.startedAt, heartbeatAt: state.heartbeatAt, heartbeatAgeSeconds: heartbeatAgeS, lastBatchAt: state.lastBatchAt, lastBatchExit: state.lastBatchExit, currentBatchStartedAt: state.currentBatchStartedAt, consecutiveFailures: state.consecutiveFailures ?? 0, markers: { tokenExpired, unhealthy, }, cache_free_bytes: cacheFreeBytes, }; return { httpStatus, body }; } const server = http.createServer(async (req, res) => { if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok\n'); return; } if (req.url === '/health') { try { const { httpStatus, body } = await buildResponse(); res.writeHead(httpStatus, { 'content-type': 'application/json', 'cache-control': 'no-store', }); res.end(JSON.stringify(body, null, 2)); } catch (err) { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ status: 'error', error: String(err) })); } return; } res.writeHead(404, { 'content-type': 'text/plain' }); res.end('not found\n'); }); server.listen(PORT, '0.0.0.0', () => { console.log(`[health-server] listening on :${PORT}`); }); for (const sig of ['SIGTERM', 'SIGINT']) { process.on(sig, () => { console.log(`[health-server] received ${sig}, shutting down`); server.close(() => process.exit(0)); setTimeout(() => process.exit(1), 5000).unref(); }); }