Drie fixes om de container lokaal (en op de NAS) te kunnen builden en draaien: - Dockerfile: clone scrum4me-mcp zonder --recurse-submodules. De Prisma- schema zit al gecommit in het scrum4me-mcp repo; de vendor/scrum4me submodule is alleen nodig voor schema-updates en wijst naar een privaat repo dat tijdens docker build niet bereikbaar is. - Dockerfile: voeg /usr/sbin en /sbin toe aan PATH zodat gosu (in /usr/sbin/gosu na apt-install) gevonden wordt door entrypoint.sh. Zonder dit faalt de container in een restart loop. - Verplaats alle runner scripts naar bin/ en maak etc/ aan, zodat COPY bin/ en COPY etc/ in de Dockerfile bestanden vinden. Verder: - .gitattributes om CRLF-corruptie van shell scripts op Windows te voorkomen (core.autocrlf=true is default actief). - .gitignore: docker-compose.override.yml uitsluiten zodat lokale dev-overrides niet worden gecommit. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
129 lines
3.9 KiB
JavaScript
129 lines
3.9 KiB
JavaScript
#!/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 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');
|
|
|
|
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;
|
|
|
|
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,
|
|
},
|
|
};
|
|
|
|
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();
|
|
});
|
|
}
|