diff --git a/CLAUDE.md b/CLAUDE.md index 0a6ab83..73aad93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,12 +38,16 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \ ## Tests -- `npm test` (vitest) — couvre `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) +- `npm test` (vitest) — 20 cas + - `__tests__/findings.test.js` — `/defenseurs/findings` (14 cas : auth, validation, filtres severity/category, asymetrie INFO, scan clean vs no_data, JSON corrompu) + - `__tests__/health.test.js` — module `metrics.js` + `/health` (6 cas : exports, payload champ pour champ, garde de latence) - Runtime reste 0-dep ; vitest en devDep uniquement ## Gotchas - Pas d'Express — HTTP natif Node.js uniquement +- Le `COPY` du Dockerfile liste les fichiers un par un (`package.json index.js metrics.js`) : tout nouveau module runtime doit y etre ajoute, sinon le conteneur plante au demarrage sur un `MODULE_NOT_FOUND` que les tests locaux ne voient pas. +- `getHealth()` garde `Promise.all([collectMetrics(), getLogtoHealth()])` : l'echantillon CPU de 500 ms et le check Logto (timeout 3 s) sont deliberement concurrents. Les serialiser ferait monter le p99 de `/health` a ~3,5 s. Garde de non-regression : le test de latence dans `__tests__/health.test.js`. - Le `status.json` et `agents-map.json` sont ecrits par le Sergent defenseurs, pas par cette API (read-only) - `agents-map.json` doit etre present sur le VPS avant le deploy : verifier via `ssh ubuntu@vps 'ls /data/defenseurs/agents-map.json'` (PAS `/home/defenseur/...`, leurre obsolete). Sinon `/defenseurs/findings` retourne 500. - Coolify ignore silencieusement `-v` dans `custom_docker_run_options` — les volumes passent par les Persistent Storages (UI seulement). Details dans `la-compagnie-maximus/docs/coolify-ops.md`. diff --git a/Dockerfile b/Dockerfile index ded3be7..8def940 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ FROM node:22-alpine WORKDIR /app -COPY package.json index.js ./ +COPY package.json index.js metrics.js ./ EXPOSE 3001 USER node CMD ["node", "index.js"] diff --git a/__tests__/health.test.js b/__tests__/health.test.js new file mode 100644 index 0000000..ca66060 --- /dev/null +++ b/__tests__/health.test.js @@ -0,0 +1,139 @@ +const http = require("node:http"); +const { setTimeout: delay } = require("node:timers/promises"); + +const { getCpuPercent, getDisk, collectMetrics } = require("../metrics.js"); + +const TOKEN = "test-token"; + +let logtoServer; +let logtoUrl; +let logtoDelayMs; +let server; +let baseUrl; +let handler; + +// Stand-in for the Logto .well-known endpoint, with a settable delay so we can +// simulate a slow IdP without touching the network. +function startLogtoStub() { + logtoDelayMs = 0; + logtoServer = http.createServer(async (req, res) => { + if (logtoDelayMs > 0) await delay(logtoDelayMs); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ issuer: "http://127.0.0.1/oidc" })); + }); + return new Promise((resolve) => { + logtoServer.listen(0, "127.0.0.1", () => { + const { port } = logtoServer.address(); + logtoUrl = `http://127.0.0.1:${port}/oidc/.well-known/openid-configuration`; + resolve(); + }); + }); +} + +function startServer() { + // Fresh import so module-level constants pick up the stubbed env. + delete require.cache[require.resolve("../index.js")]; + ({ handler } = require("../index.js")); + server = http.createServer(handler); + return new Promise((resolve) => { + server.listen(0, () => { + const { port } = server.address(); + baseUrl = `http://127.0.0.1:${port}`; + resolve(); + }); + }); +} + +function close(target) { + return new Promise((resolve) => { + if (!target) return resolve(); + target.close(() => resolve()); + }); +} + +async function get(path, { auth = `Bearer ${TOKEN}` } = {}) { + const headers = {}; + if (auth) headers.Authorization = auth; + const res = await fetch(`${baseUrl}${path}`, { headers }); + const body = await res.json().catch(() => ({})); + return { status: res.status, body }; +} + +beforeEach(async () => { + await startLogtoStub(); + process.env.HEALTH_TOKEN = TOKEN; + process.env.LOGTO_HEALTH_URL = logtoUrl; + await startServer(); +}); + +afterEach(async () => { + await close(server); + await close(logtoServer); +}); + +describe("metrics module", () => { + test("exposes getCpuPercent, getDisk and collectMetrics", () => { + expect(typeof getCpuPercent).toBe("function"); + expect(typeof getDisk).toBe("function"); + expect(typeof collectMetrics).toBe("function"); + }); + + test("getDisk returns the four numeric disk fields", () => { + const disk = getDisk(); + expect(Object.keys(disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); + for (const value of Object.values(disk)) expect(typeof value).toBe("number"); + }); + + test("collectMetrics returns the cpu/memory/disk slice of /health", async () => { + const metrics = await collectMetrics(); + expect(Object.keys(metrics)).toEqual(["cpu", "memory", "disk"]); + expect(Object.keys(metrics.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]); + expect(Object.keys(metrics.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); + expect(metrics.cpu.cores).toBeGreaterThan(0); + expect(Array.isArray(metrics.cpu.loadAvg)).toBe(true); + }); +}); + +describe("GET /health", () => { + test("401 on invalid token", async () => { + const { status } = await get("/health", { auth: "Bearer wrong" }); + expect(status).toBe(401); + }); + + test("200 returns the documented payload, field for field", async () => { + const { status, body } = await get("/health"); + expect(status).toBe(200); + // Key order is part of the contract consumed by the admin dashboard. + expect(Object.keys(body)).toEqual([ + "timestamp", + "hostname", + "uptime", + "cpu", + "memory", + "disk", + "logto", + ]); + expect(Object.keys(body.cpu)).toEqual(["model", "cores", "loadAvg", "usagePercent"]); + expect(Object.keys(body.memory)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); + expect(Object.keys(body.disk)).toEqual(["totalGB", "usedGB", "freeGB", "usagePercent"]); + expect(body.logto.status).toBe("up"); + expect(typeof body.logto.responseTimeMs).toBe("number"); + expect(typeof body.uptime).toBe("number"); + expect(typeof body.hostname).toBe("string"); + expect(new Date(body.timestamp).toISOString()).toBe(body.timestamp); + }); + + // Latency regression guard. Metrics collection (500ms CPU sample) and the + // Logto check must run concurrently: serializing them would make /health take + // 500ms + the Logto response time. With a 1200ms Logto, concurrent lands at + // ~1200ms while serialized lands at ~1700ms — only the former clears 1.5s. + test("stays under 1.5s with a slow Logto (metrics stay concurrent)", async () => { + logtoDelayMs = 1200; + const start = performance.now(); + const { status, body } = await get("/health"); + const elapsed = performance.now() - start; + expect(status).toBe(200); + expect(body.logto.status).toBe("up"); + expect(elapsed).toBeLessThan(1500); + }, 15000); +}); diff --git a/index.js b/index.js index 787eed1..a009697 100644 --- a/index.js +++ b/index.js @@ -1,9 +1,8 @@ const http = require("node:http"); const os = require("node:os"); -const { execSync } = require("node:child_process"); const { readFileSync, readdirSync, existsSync } = require("node:fs"); const path = require("node:path"); -const { setTimeout: delay } = require("node:timers/promises"); +const { collectMetrics } = require("./metrics.js"); const PORT = parseInt(process.env.PORT || "3001", 10); const TOKEN = process.env.HEALTH_TOKEN; @@ -38,35 +37,6 @@ if (!TOKEN) { console.warn("WARNING: HEALTH_TOKEN is not set. All requests will be rejected (fail-closed)."); } -function readProcStat() { - try { - const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim(); - const parts = line.split(/\s+/).slice(1).map(Number); - const idle = parts[3] + parts[4]; - const total = parts.reduce((a, b) => a + b, 0); - return { idle, total }; - } catch { - return null; - } -} - -async function getCpuPercent() { - const t1 = readProcStat(); - if (!t1) return 0; - const { idle: idle1, total: total1 } = t1; - - // Sample over 500ms without blocking the event loop, so other async work - // (e.g. the Logto healthcheck) can run concurrently. - await delay(500); - - const t2 = readProcStat(); - if (!t2) return 0; - const dIdle = t2.idle - idle1; - const dTotal = t2.total - total1; - if (dTotal === 0) return 0; - return Math.round((1 - dIdle / dTotal) * 100); -} - async function getLogtoHealth() { const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS); @@ -85,22 +55,6 @@ async function getLogtoHealth() { } } -function getDisk() { - try { - // Alpine df doesn't support --output, use standard POSIX format - const out = execSync("df -k /", { encoding: "utf-8" }); - const parts = out.trim().split("\n")[1].trim().split(/\s+/); - // df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted - const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1); - const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1); - const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1); - const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0; - return { totalGB, usedGB, freeGB, usagePercent }; - } catch { - return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 }; - } -} - // Reproduce the isScanReport guard from defenseurs/src/report.ts. The // defenseur-auto run report has shape { actions[], skipped[] } with no // findings[] — it must be filtered out so it never reaches the auto pipeline @@ -207,13 +161,11 @@ function findLatestReportForAgent(agent) { } async function getHealth() { - const cpus = os.cpus(); - const totalMem = os.totalmem(); - const freeMem = os.freemem(); - const usedMem = totalMem - freeMem; - - const [cpuUsagePercent, logto] = await Promise.all([ - getCpuPercent(), + // Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the + // up-to-3s Logto check must stay concurrent. Awaiting them one after the + // other would push the p99 of /health to ~3.5s. + const [metrics, logto] = await Promise.all([ + collectMetrics(), getLogtoHealth(), ]); @@ -221,19 +173,7 @@ async function getHealth() { timestamp: new Date().toISOString(), hostname: os.hostname(), uptime: Math.floor(os.uptime()), - cpu: { - model: cpus[0]?.model?.trim() || "unknown", - cores: cpus.length, - loadAvg: os.loadavg().map((l) => +l.toFixed(2)), - usagePercent: cpuUsagePercent, - }, - memory: { - totalGB: +(totalMem / 1e9).toFixed(1), - usedGB: +(usedMem / 1e9).toFixed(1), - freeGB: +(freeMem / 1e9).toFixed(1), - usagePercent: Math.round((usedMem / totalMem) * 100), - }, - disk: getDisk(), + ...metrics, logto, }; } diff --git a/metrics.js b/metrics.js new file mode 100644 index 0000000..b652121 --- /dev/null +++ b/metrics.js @@ -0,0 +1,84 @@ +const os = require("node:os"); +const { execSync } = require("node:child_process"); +const { setTimeout: delay } = require("node:timers/promises"); + +// Host metrics collection (CPU / memory / disk), extracted from index.js so the +// same code can be reused by the local workstation agent. Pure host probing: +// no HTTP, no config, no side effects — the caller owns the response shape. + +function readProcStat() { + try { + const line = execSync("head -1 /proc/stat", { encoding: "utf-8" }).trim(); + const parts = line.split(/\s+/).slice(1).map(Number); + const idle = parts[3] + parts[4]; + const total = parts.reduce((a, b) => a + b, 0); + return { idle, total }; + } catch { + return null; + } +} + +async function getCpuPercent() { + const t1 = readProcStat(); + if (!t1) return 0; + const { idle: idle1, total: total1 } = t1; + + // Sample over 500ms without blocking the event loop, so other async work + // (e.g. the Logto healthcheck) can run concurrently. + await delay(500); + + const t2 = readProcStat(); + if (!t2) return 0; + const dIdle = t2.idle - idle1; + const dTotal = t2.total - total1; + if (dTotal === 0) return 0; + return Math.round((1 - dIdle / dTotal) * 100); +} + +function getDisk() { + try { + // Alpine df doesn't support --output, use standard POSIX format + const out = execSync("df -k /", { encoding: "utf-8" }); + const parts = out.trim().split("\n")[1].trim().split(/\s+/); + // df -k columns: Filesystem, 1K-blocks, Used, Available, Use%, Mounted + const totalGB = +(parseInt(parts[1], 10) / 1e6).toFixed(1); + const usedGB = +(parseInt(parts[2], 10) / 1e6).toFixed(1); + const freeGB = +(parseInt(parts[3], 10) / 1e6).toFixed(1); + const usagePercent = totalGB > 0 ? Math.round((usedGB / totalGB) * 100) : 0; + return { totalGB, usedGB, freeGB, usagePercent }; + } catch { + return { totalGB: 0, usedGB: 0, freeGB: 0, usagePercent: 0 }; + } +} + +// Collect CPU, memory and disk into the `{ cpu, memory, disk }` slice of the +// /health payload. Awaiting getCpuPercent() first is deliberate: the 500ms +// sample is the only await here, so a caller running this inside a +// Promise.all() keeps its other work (the Logto check) fully concurrent. +// Everything else is cheap and synchronous. +async function collectMetrics() { + const cpus = os.cpus(); + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + + const cpuUsagePercent = await getCpuPercent(); + + return { + cpu: { + model: cpus[0]?.model?.trim() || "unknown", + cores: cpus.length, + loadAvg: os.loadavg().map((l) => +l.toFixed(2)), + usagePercent: cpuUsagePercent, + }, + memory: { + totalGB: +(totalMem / 1e9).toFixed(1), + usedGB: +(usedMem / 1e9).toFixed(1), + freeGB: +(freeMem / 1e9).toFixed(1), + usagePercent: Math.round((usedMem / totalMem) * 100), + }, + disk: getDisk(), + }; +} + +module.exports = { getCpuPercent, getDisk, collectMetrics };