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:
parent
39a55fad93
commit
79cb813767
5 changed files with 236 additions and 69 deletions
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
139
__tests__/health.test.js
Normal file
139
__tests__/health.test.js
Normal 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);
|
||||
});
|
||||
74
index.js
74
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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
84
metrics.js
Normal file
84
metrics.js
Normal 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 };
|
||||
Loading…
Reference in a new issue