fix: lokale Docker build werkend krijgen

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>
This commit is contained in:
Janpeter Visser 2026-05-02 19:18:35 +02:00
parent 9d8a7fe237
commit 47b1de93db
12 changed files with 17 additions and 5 deletions

129
bin/health-server.js Normal file
View file

@ -0,0 +1,129 @@
#!/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();
});
}