Files
archy/docker/dojobay/server/check-resources.ts
T

236 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// =============================================================================
// The Dojo Bay — resource diagnostic.
//
// READ-ONLY. Measures what this instance actually uses, rather than guessing,
// so an operator can size a VPS from evidence and this project can document a
// requirement it has tested.
//
// What it looks at, and why each matters for THIS workload:
//
// memory the backend is a small long-running Node process; the updater is
// a second one every ten minutes; tor and nginx sit alongside.
// Peak matters more than current, because `npm ci` during a deploy
// and the unzip during a self-update are the two spikes.
// disk node_modules, the published data, and — the one that grows
// without limit — data/backups, a full copy of the code kept by
// every self-update.
// cpu idle almost always, with a burst each probe cycle: one Tor
// circuit per listed node, plus secp256k1 verification.
// strain swap in use, OOM kills and load average are the evidence that a
// box is actually too small, as opposed to merely modest.
//
// NO PATH FROM THE ENVIRONMENT REACHES A SUBPROCESS. WEB_ROOT and
// PUBLIC_DATA_DIR are operator-set, and this file used to hand them to `df` and
// `du`, which CodeQL flagged (js/shell-command-injection-from-environment) and
// which is a real if narrow bug: a value beginning with a hyphen is read by
// those tools as an option, not a path, so `WEB_ROOT=-x` silently measures
// something other than what was asked for. Both are now answered by Node
// itself, statfs() and a walk, which removes the class rather than escaping
// around it. The two subprocesses that remain (systemctl, journalctl) exist
// because nothing in Node can answer what they answer, and both take arguments
// written here. Keep it that way: see sh() below.
//
// Usage, on the box:
// cd /var/www/dojobay/server && node check-resources.ts
// =============================================================================
import { readFile, stat, readdir, lstat, statfs } from "node:fs/promises";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import path from "node:path";
import os from "node:os";
import { fileURLToPath, pathToFileURL } from "node:url";
const exec = promisify(execFile);
const HERE = path.dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = process.env.WEB_ROOT || path.resolve(HERE, "..");
const PUBLIC_DIR = process.env.PUBLIC_DATA_DIR || path.join(WEB_ROOT, "data");
const MB = 1024 * 1024;
const mb = (bytes: number) => {
if (bytes < 1024) return bytes + " B";
if (bytes < MB) return (bytes / 1024).toFixed(0) + " KB";
return (bytes / MB).toFixed(bytes < 10 * MB ? 1 : 0) + " MB";
};
const gb = (bytes: number) => (bytes / (1024 * MB)).toFixed(1) + " GB";
const read = async (p: string) => { try { return await readFile(p, "utf8"); } catch { return null; } };
// Every call site passes a command and an argument list written in this file,
// never a path, a name or anything else derived from the environment. The one
// exception is UNITS below, which the suite checks directly. A future edit that
// interpolates a variable in here fails the gate rather than shipping.
const sh = async (cmd: string, args: string[]) => {
try { return (await exec(cmd, args)).stdout.trim(); } catch { return null; }
};
// The only values this file passes to a subprocess that are not written inline
// at the call site. They are exported so the suite can assert on the array
// itself rather than reading this source and guessing: an assertion about what
// a program does is worth more than one about how it is spelled.
export const UNITS = [
"dojobay-server.service",
"dojobay-update.service",
"tor.service",
"nginx.service",
];
// Replaces `df`. statfs reports the filesystem holding the path, and the
// arithmetic matches what df prints: used counts the blocks the filesystem
// considers occupied, while available excludes the root reserve, so used plus
// available is legitimately less than the total.
export const diskUsage = async (p: string) => {
try {
const fs = await statfs(p);
const block = Number(fs.bsize);
return {
size: Number(fs.blocks) * block,
used: (Number(fs.blocks) - Number(fs.bfree)) * block,
avail: Number(fs.bavail) * block,
};
} catch { return null; }
};
// Replaces `du -sb`: apparent size of a tree, symlinks counted but never
// followed, unreadable entries skipped rather than fatal, and directory inodes
// excluded, which is what `du -sb` does and is why this agrees with it to the
// byte on a real node_modules. Counting the directories instead would add 4 KB
// per directory of filesystem bookkeeping to a figure meant to describe
// content. One difference remains: du counts a hard-linked file once, this
// counts it once per link, which node_modules does not contain and which would
// overstate rather than hide. It also walks in JavaScript, so a populated
// node_modules takes a second or so rather than being instant, which is nothing
// for a diagnostic run by hand a few times a year.
export const dirSize = async (p: string): Promise<number | null> => {
const root = await lstat(p).catch(() => null);
if (!root) return null;
if (!root.isDirectory()) return root.size;
let total = 0;
const walk = async (dir: string) => {
const entries = await readdir(dir, { withFileTypes: true }).catch(() => null);
if (!entries) return;
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { await walk(full); continue; }
const s = await lstat(full).catch(() => null);
if (s) total += s.size;
}
};
await walk(p);
return total;
};
const report = async () => {
console.log("The Dojo Bay — what this instance actually uses\n");
// ---- the machine ----------------------------------------------------------
const meminfo = (await read("/proc/meminfo")) || "";
const kb = (key: string) => {
const m = meminfo.match(new RegExp("^" + key + ":\\s+(\\d+) kB", "m"));
return m ? Number(m[1]) * 1024 : null;
};
const memTotal = kb("MemTotal"), memAvail = kb("MemAvailable");
const swapTotal = kb("SwapTotal"), swapFree = kb("SwapFree");
const swapUsed = swapTotal != null && swapFree != null ? swapTotal - swapFree : null;
const cpus = os.cpus();
console.log("MACHINE");
console.log(` cpu ${cpus.length} × ${cpus[0]?.model?.trim() || "unknown"}`);
console.log(` memory ${memTotal ? gb(memTotal) : "?"} total, ${memAvail ? gb(memAvail) : "?"} available`);
console.log(` swap ${swapTotal ? gb(swapTotal) + " total, " + mb(swapUsed || 0) + " in use" : "none configured"}`);
const la = os.loadavg();
console.log(` load average ${la.map((n) => n.toFixed(2)).join(" ")} (1, 5, 15 min; ${cpus.length} core${cpus.length === 1 ? "" : "s"})`);
const disk = await diskUsage(WEB_ROOT);
const diskFree = disk ? disk.avail : null;
if (disk) console.log(` disk ${gb(disk.size)} total, ${gb(disk.used)} used, ${gb(disk.avail)} free`);
// ---- what our services use ------------------------------------------------
console.log("\nSERVICES (current / peak since boot)");
let ourPeak = 0;
for (const unit of UNITS) {
const base = `/sys/fs/cgroup/system.slice/${unit}`;
const cur = Number((await read(`${base}/memory.current`)) || 0);
const peak = Number((await read(`${base}/memory.peak`)) || 0);
const active = await sh("systemctl", ["is-active", unit]);
if (!cur && active !== "active") { console.log(` ${unit.padEnd(24)} not running`); continue; }
if (unit.startsWith("dojobay")) ourPeak += peak || cur;
console.log(` ${unit.padEnd(24)} ${cur ? mb(cur) : "—"}${peak ? " / " + mb(peak) : ""}`);
}
// ---- disk, broken down ----------------------------------------------------
console.log("\nDISK USED BY THIS INSTALLATION");
const parts: [string, string][] = [
["everything", WEB_ROOT],
[" server/node_modules", path.join(WEB_ROOT, "server", "node_modules")],
[" data (published)", PUBLIC_DIR],
[" data/avatars", path.join(PUBLIC_DIR, "avatars")],
[" data/backups", path.join(PUBLIC_DIR, "backups")],
[" data/updates", path.join(PUBLIC_DIR, "updates")],
];
let backupsBytes = 0, backupCount = 0;
for (const [label, p] of parts) {
const bytes = await dirSize(p);
if (bytes == null) { console.log(` ${label.padEnd(24)} —`); continue; }
if (label.includes("backups")) {
backupsBytes = bytes;
try { backupCount = (await readdir(p)).length; } catch { /* none */ }
}
console.log(` ${label.padEnd(24)} ${mb(bytes)}${label.includes("backups") && backupCount ? ` (${backupCount} kept)` : ""}`);
}
// ---- the workload ---------------------------------------------------------
console.log("\nWORKLOAD");
let nodeCount = 0, intervalMin = 10;
try {
const dojos = JSON.parse((await read(path.join(PUBLIC_DIR, "dojos.json"))) || "{}");
nodeCount = (dojos.nodes || []).length;
intervalMin = Number(dojos.interval_minutes) || 10;
} catch { /* not built yet */ }
const concurrency = Number(process.env.CONCURRENCY || 4);
console.log(` listed nodes ${nodeCount}`);
console.log(` probe cycle every ${intervalMin} min, up to ${concurrency} Tor circuits at once`);
for (const f of ["dojos.json", "history.json", "history-daily.json"]) {
const s = await stat(path.join(PUBLIC_DIR, f)).catch(() => null);
if (s) console.log(` ${f.padEnd(22)} ${mb(s.size)}`);
}
// ---- evidence of strain ---------------------------------------------------
// journalctl does its own matching, so there is no pipeline and no shell: the
// filter is an argument, the output is one line per matching entry, and a
// journalctl that cannot answer leaves this null exactly as an absent one did.
console.log("\nSIGNS OF STRAIN");
const oom = await sh("journalctl", ["-k", "--no-pager", "--case-sensitive=false",
"--grep=out of memory", "--output=cat"]);
const oomCount = oom ? oom.split("\n").filter((l) => l.trim()).length : 0;
const findings: string[] = [];
if (oomCount > 0) findings.push(`${oomCount} out-of-memory event(s) in the kernel log — the box IS too small`);
if (swapUsed && swapUsed > 64 * MB) findings.push(`${mb(swapUsed)} of swap in use — memory pressure, though not fatal`);
if (memAvail && memTotal && memAvail < memTotal * 0.15) findings.push("under 15% of memory available right now");
if (la[2] > cpus.length) findings.push(`15-minute load ${la[2].toFixed(2)} exceeds ${cpus.length} core(s)`);
if (diskFree != null && diskFree < 2 * 1024 * MB) findings.push(`only ${gb(diskFree)} of disk free`);
if (backupCount > 3) findings.push(`${backupCount} self-update backups kept (${mb(backupsBytes)}); nothing prunes these`);
if (!findings.length) console.log(" none. Nothing here suggests this machine is short of anything.");
else for (const f of findings) console.log(` · ${f}`);
// ---- what to tell other operators -----------------------------------------
console.log("\nWHAT THIS SUGGESTS FOR A MINIMUM SPEC");
const ourMb = ourPeak / MB;
if (ourPeak > 0) {
console.log(` This instance's own services peaked at about ${mb(ourPeak)}, carrying ${nodeCount} node(s).`);
console.log(" Add tor, nginx and the operating system, and headroom for `npm ci`");
console.log(" during a deploy, which is the largest transient by some way.");
} else {
console.log(" The services are not running here, so nothing was measured. Run this ON");
console.log(" the instance, with the backend up, for numbers that mean anything.");
}
console.log("");
console.log(` Suggested minimum: 1 vCPU, ${ourPeak > 0 && ourMb < 200 ? "1 GB" : "2 GB"} RAM, 20 GB disk, plus swap.`);
console.log(" The work is almost entirely waiting on Tor, so cores buy little; memory");
console.log(" and a little disk headroom are what matter. Run this again after a");
console.log(" deploy and after a self-update to catch the peaks rather than the calm.");
};
// Run when invoked, importable when tested. The suite exercises dirSize and
// diskUsage directly; printing a report on import would make that impossible.
if (import.meta.url === pathToFileURL(process.argv[1] || "").href) await report();