Merge pull request 'feat: add Logto healthcheck to /health endpoint' (#2) from issue-1-logto-healthcheck into main
This commit is contained in:
commit
fc3c3a9268
3 changed files with 73 additions and 9 deletions
|
|
@ -1,2 +1,3 @@
|
|||
PORT=3001
|
||||
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 { execSync } = require("node:child_process");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3001", 10);
|
||||
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) {
|
||||
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();
|
||||
if (!t1) return 0;
|
||||
const { idle: idle1, total: total1 } = t1;
|
||||
|
||||
// Sample over 500ms
|
||||
execSync("sleep 0.5");
|
||||
// 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;
|
||||
|
|
@ -38,6 +44,24 @@ function getCpuPercent() {
|
|||
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() {
|
||||
try {
|
||||
// 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 totalMem = os.totalmem();
|
||||
const freeMem = os.freemem();
|
||||
const usedMem = totalMem - freeMem;
|
||||
|
||||
const [cpuUsagePercent, logto] = await Promise.all([
|
||||
getCpuPercent(),
|
||||
getLogtoHealth(),
|
||||
]);
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
hostname: os.hostname(),
|
||||
|
|
@ -68,7 +97,7 @@ function getHealth() {
|
|||
model: cpus[0]?.model?.trim() || "unknown",
|
||||
cores: cpus.length,
|
||||
loadAvg: os.loadavg().map((l) => +l.toFixed(2)),
|
||||
usagePercent: getCpuPercent(),
|
||||
usagePercent: cpuUsagePercent,
|
||||
},
|
||||
memory: {
|
||||
totalGB: +(totalMem / 1e9).toFixed(1),
|
||||
|
|
@ -77,10 +106,11 @@ function getHealth() {
|
|||
usagePercent: Math.round((usedMem / totalMem) * 100),
|
||||
},
|
||||
disk: getDisk(),
|
||||
logto,
|
||||
};
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const server = http.createServer(async (req, res) => {
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
|
||||
const validRoutes = ["/health", "/defenseurs"];
|
||||
|
|
@ -116,9 +146,14 @@ const server = http.createServer((req, res) => {
|
|||
return;
|
||||
}
|
||||
|
||||
const data = getHealth();
|
||||
try {
|
||||
const data = await getHealth();
|
||||
res.writeHead(200);
|
||||
res.end(JSON.stringify(data));
|
||||
} catch (err) {
|
||||
res.writeHead(500);
|
||||
res.end(JSON.stringify({ error: "Internal error", message: err.message }));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue