refactor: extract CPU/RAM/disk collection into metrics.js

Move readProcStat, getCpuPercent, getDisk and the cpu/memory/disk payload
assembly out of index.js into a standalone metrics.js, so the upcoming
local workstation agent can reuse the same collection code. No behaviour
change: /health returns the exact same fields, in the same order.

Notes:
- collectMetrics() returns the { cpu, memory, disk } slice; getHealth()
  spreads it, keeping the JSON key order the admin dashboard relies on.
- getHealth() keeps Promise.all([collectMetrics(), getLogtoHealth()]).
  The 500ms CPU sample and the 3s Logto check are deliberately concurrent;
  serializing them would push the p99 of /health to ~3.5s.
- collectMetrics() awaits the CPU sample as its only await, so callers
  running it inside a Promise.all keep their concurrency.
- Dockerfile COPY lists files one by one, so metrics.js had to be added
  there or the container would crash on MODULE_NOT_FOUND at startup.
- New __tests__/health.test.js: metrics.js exports, field-for-field
  payload shape, and a latency guard (<1.5s with a 1200ms stubbed Logto).
  Verified by mutation: serializing the two calls fails the latency test
  at ~1743ms while the field comparison still passes.
- Runtime stays 0-dependency; the 14 existing tests are untouched.

Resolves #11
This commit is contained in:
le king fu 2026-08-16 11:30:03 -04:00
parent 39a55fad93
commit 79cb813767
5 changed files with 236 additions and 69 deletions

View file

@ -38,12 +38,16 @@ curl -H "Authorization: Bearer $(cat ~/.coolify-token)" \
## Tests ## 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 - Runtime reste 0-dep ; vitest en devDep uniquement
## Gotchas ## Gotchas
- Pas d'Express — HTTP natif Node.js uniquement - 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) - 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. - `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`. - 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`.

View file

@ -1,6 +1,6 @@
FROM node:22-alpine FROM node:22-alpine
WORKDIR /app WORKDIR /app
COPY package.json index.js ./ COPY package.json index.js metrics.js ./
EXPOSE 3001 EXPOSE 3001
USER node USER node
CMD ["node", "index.js"] CMD ["node", "index.js"]

139
__tests__/health.test.js Normal file
View file

@ -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);
});

View file

@ -1,9 +1,8 @@
const http = require("node:http"); const http = require("node:http");
const os = require("node:os"); const os = require("node:os");
const { execSync } = require("node:child_process");
const { readFileSync, readdirSync, existsSync } = require("node:fs"); const { readFileSync, readdirSync, existsSync } = require("node:fs");
const path = require("node:path"); 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 PORT = parseInt(process.env.PORT || "3001", 10);
const TOKEN = process.env.HEALTH_TOKEN; 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)."); 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() { async function getLogtoHealth() {
const ac = new AbortController(); const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS); 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 // Reproduce the isScanReport guard from defenseurs/src/report.ts. The
// defenseur-auto run report has shape { actions[], skipped[] } with no // defenseur-auto run report has shape { actions[], skipped[] } with no
// findings[] — it must be filtered out so it never reaches the auto pipeline // findings[] — it must be filtered out so it never reaches the auto pipeline
@ -207,13 +161,11 @@ function findLatestReportForAgent(agent) {
} }
async function getHealth() { async function getHealth() {
const cpus = os.cpus(); // Keep the Promise.all: the 500ms CPU sample inside collectMetrics() and the
const totalMem = os.totalmem(); // up-to-3s Logto check must stay concurrent. Awaiting them one after the
const freeMem = os.freemem(); // other would push the p99 of /health to ~3.5s.
const usedMem = totalMem - freeMem; const [metrics, logto] = await Promise.all([
collectMetrics(),
const [cpuUsagePercent, logto] = await Promise.all([
getCpuPercent(),
getLogtoHealth(), getLogtoHealth(),
]); ]);
@ -221,19 +173,7 @@ async function getHealth() {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
hostname: os.hostname(), hostname: os.hostname(),
uptime: Math.floor(os.uptime()), uptime: Math.floor(os.uptime()),
cpu: { ...metrics,
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(),
logto, logto,
}; };
} }

84
metrics.js Normal file
View file

@ -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 };