feat: add Logto healthcheck to /health endpoint
Fixes #1. - New `logto: {status, responseTimeMs, error?}` field in /health response - Configurable via LOGTO_HEALTH_URL env (default: auth.lacompagniemaximus.com OIDC discovery endpoint) - 3s timeout via AbortController; /health stays HTTP 200 even if Logto is down - getCpuPercent converted to async (setTimeout-based delay) so the 500ms CPU sample and the Logto fetch run concurrently via Promise.all; total latency stays max(500ms, <=3000ms) instead of the sum - Commit project CLAUDE.md (previously untracked) with the new field documented Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9a4c5c7775
commit
28dd759f98
3 changed files with 73 additions and 9 deletions
|
|
@ -1,2 +1,3 @@
|
||||||
PORT=3001
|
PORT=3001
|
||||||
HEALTH_TOKEN=change-me-to-a-strong-secret
|
HEALTH_TOKEN=change-me-to-a-strong-secret
|
||||||
|
LOGTO_HEALTH_URL=https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration
|
||||||
|
|
|
||||||
28
CLAUDE.md
Normal file
28
CLAUDE.md
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
# VPS Health API
|
||||||
|
|
||||||
|
API sante minimaliste pour le VPS. ~127 lignes, Node 22 + HTTP natif.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /health` — CPU, memoire, disque, uptime, logto (`{status, responseTimeMs, error?}`)
|
||||||
|
- `GET /defenseurs` — contenu de status.json (rapports defenseurs)
|
||||||
|
|
||||||
|
## Auth
|
||||||
|
|
||||||
|
- Bearer token via env `HEALTH_TOKEN`
|
||||||
|
- Fail-closed : si `HEALTH_TOKEN` non configure, toutes les requetes sont refusees
|
||||||
|
|
||||||
|
## Config
|
||||||
|
|
||||||
|
- Port : `3001` (env `PORT`)
|
||||||
|
- `LOGTO_HEALTH_URL` : URL du `.well-known/openid-configuration` (default auth.lacompagniemaximus.com)
|
||||||
|
- Bind-mount : `/data/defenseurs/status.json` read-only
|
||||||
|
|
||||||
|
## Deploy
|
||||||
|
|
||||||
|
Coolify auto-rebuild depuis push Forgejo. Aucune action manuelle requise.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Pas d'Express — HTTP natif Node.js uniquement
|
||||||
|
- Le `status.json` est ecrit par le Sergent defenseurs, pas par cette API (read-only)
|
||||||
49
index.js
49
index.js
|
|
@ -2,9 +2,14 @@ const http = require("node:http");
|
||||||
const os = require("node:os");
|
const os = require("node:os");
|
||||||
const { execSync } = require("node:child_process");
|
const { execSync } = require("node:child_process");
|
||||||
const { readFileSync } = require("node:fs");
|
const { readFileSync } = require("node:fs");
|
||||||
|
const { setTimeout: delay } = require("node:timers/promises");
|
||||||
|
|
||||||
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;
|
||||||
|
const LOGTO_HEALTH_URL =
|
||||||
|
process.env.LOGTO_HEALTH_URL ||
|
||||||
|
"https://auth.lacompagniemaximus.com/oidc/.well-known/openid-configuration";
|
||||||
|
const LOGTO_TIMEOUT_MS = 3000;
|
||||||
|
|
||||||
if (!TOKEN) {
|
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).");
|
||||||
|
|
@ -22,13 +27,14 @@ function readProcStat() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCpuPercent() {
|
async function getCpuPercent() {
|
||||||
const t1 = readProcStat();
|
const t1 = readProcStat();
|
||||||
if (!t1) return 0;
|
if (!t1) return 0;
|
||||||
const { idle: idle1, total: total1 } = t1;
|
const { idle: idle1, total: total1 } = t1;
|
||||||
|
|
||||||
// Sample over 500ms
|
// Sample over 500ms without blocking the event loop, so other async work
|
||||||
execSync("sleep 0.5");
|
// (e.g. the Logto healthcheck) can run concurrently.
|
||||||
|
await delay(500);
|
||||||
|
|
||||||
const t2 = readProcStat();
|
const t2 = readProcStat();
|
||||||
if (!t2) return 0;
|
if (!t2) return 0;
|
||||||
|
|
@ -38,6 +44,24 @@ function getCpuPercent() {
|
||||||
return Math.round((1 - dIdle / dTotal) * 100);
|
return Math.round((1 - dIdle / dTotal) * 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getLogtoHealth() {
|
||||||
|
const ac = new AbortController();
|
||||||
|
const timer = setTimeout(() => ac.abort(), LOGTO_TIMEOUT_MS);
|
||||||
|
const start = performance.now();
|
||||||
|
try {
|
||||||
|
const res = await fetch(LOGTO_HEALTH_URL, { signal: ac.signal });
|
||||||
|
const responseTimeMs = Math.round(performance.now() - start);
|
||||||
|
if (res.ok) return { status: "up", responseTimeMs };
|
||||||
|
return { status: "down", responseTimeMs, error: `HTTP ${res.status}` };
|
||||||
|
} catch (err) {
|
||||||
|
const responseTimeMs = Math.round(performance.now() - start);
|
||||||
|
const error = err.name === "AbortError" ? "timeout" : err.message || "network error";
|
||||||
|
return { status: "down", responseTimeMs, error };
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getDisk() {
|
function getDisk() {
|
||||||
try {
|
try {
|
||||||
// Alpine df doesn't support --output, use standard POSIX format
|
// Alpine df doesn't support --output, use standard POSIX format
|
||||||
|
|
@ -54,12 +78,17 @@ function getDisk() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHealth() {
|
async function getHealth() {
|
||||||
const cpus = os.cpus();
|
const cpus = os.cpus();
|
||||||
const totalMem = os.totalmem();
|
const totalMem = os.totalmem();
|
||||||
const freeMem = os.freemem();
|
const freeMem = os.freemem();
|
||||||
const usedMem = totalMem - freeMem;
|
const usedMem = totalMem - freeMem;
|
||||||
|
|
||||||
|
const [cpuUsagePercent, logto] = await Promise.all([
|
||||||
|
getCpuPercent(),
|
||||||
|
getLogtoHealth(),
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
hostname: os.hostname(),
|
hostname: os.hostname(),
|
||||||
|
|
@ -68,7 +97,7 @@ function getHealth() {
|
||||||
model: cpus[0]?.model?.trim() || "unknown",
|
model: cpus[0]?.model?.trim() || "unknown",
|
||||||
cores: cpus.length,
|
cores: cpus.length,
|
||||||
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
|
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
|
||||||
usagePercent: getCpuPercent(),
|
usagePercent: cpuUsagePercent,
|
||||||
},
|
},
|
||||||
memory: {
|
memory: {
|
||||||
totalGB: +(totalMem / 1e9).toFixed(1),
|
totalGB: +(totalMem / 1e9).toFixed(1),
|
||||||
|
|
@ -77,10 +106,11 @@ function getHealth() {
|
||||||
usagePercent: Math.round((usedMem / totalMem) * 100),
|
usagePercent: Math.round((usedMem / totalMem) * 100),
|
||||||
},
|
},
|
||||||
disk: getDisk(),
|
disk: getDisk(),
|
||||||
|
logto,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
res.setHeader("Content-Type", "application/json");
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
|
||||||
const validRoutes = ["/health", "/defenseurs"];
|
const validRoutes = ["/health", "/defenseurs"];
|
||||||
|
|
@ -116,9 +146,14 @@ const server = http.createServer((req, res) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = getHealth();
|
try {
|
||||||
|
const data = await getHealth();
|
||||||
res.writeHead(200);
|
res.writeHead(200);
|
||||||
res.end(JSON.stringify(data));
|
res.end(JSON.stringify(data));
|
||||||
|
} catch (err) {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue