vps-health-api/agent/push-metrics.js
le king fu e24f5b6f00 feat(agent): push workstation metrics to /hosts/<id> from cron
Zero-dependency local agent: collect one snapshot through the shared
metrics.js, POST it once to /hosts/<id>, exit. Cron runs it every five
minutes. Nothing is queued and nothing is replayed — a heartbeat from five
minutes ago describes a machine that no longer exists, so a failed push is
logged and dropped rather than spooled.

run-push.sh sources ~/.config/maximus-host-agent.env (mode 600), refuses to
start when HOSTS_API_URL, HOSTS_INGEST_TOKEN or HOST_ID is missing, and never
enables shell tracing: the script is meant to be piped into `logger`, where
`set -x` would echo the ingest token into /var/log/syslog and journald for
good. Same reason the failure path reports only err.code and the HTTP status —
never an error object, request options, headers, or a response body. The cost
is accepted: a 400 says the payload was rejected, not why.

50 tests, including two that spawn the real process against a failing server
and scan its actual stdout and stderr for any five-character fragment of the
token. The payload is pinned against the server's own sanitizeSnapshot(), and
one case pushes a real collectMetrics() snapshot through the real ingestion
handler, so a drift between agent and server turns a test red instead of
producing a 400 at 3 a.m. on the ThinkPad.

agent/ stays out of the Docker image: the COPY line is untouched, and a test
pins that it copies files one by one and never names the directory.

Installing on a workstation — frozen copy, env file, crontab line, dry run,
and why two machines must never share a HOST_ID — is documented in
agent/README.md. The install itself belongs to the commissioning issue.

Resolves #15
2026-08-16 12:21:59 -04:00

309 lines
11 KiB
JavaScript

#!/usr/bin/env node
// Local workstation agent: collect one snapshot and POST it to /hosts/<id>.
// Runs from cron every 5 minutes on the ThinkPad. Zero runtime dependencies —
// node:http / node:https only, same posture as the server it talks to.
//
// Two properties are load-bearing and easy to break by accident.
//
// * NO QUEUE, NO REPLAY. One run = one attempt. A missed beat is replaced by
// the next one five minutes later; a stale heartbeat has no value, and a
// spool file would only ever let the dashboard display a past that is no
// longer true. Every failure exits non-zero and stops there.
// * THE FAILURE PATH NEVER PRINTS A REQUEST OBJECT. run-push.sh pipes this
// process' output into `logger -t host-agent`, so anything written here
// lands in /var/log/syslog and journald permanently. An error object or an
// options bag carrying the Authorization header would persist the ingest
// token on disk, in cleartext, forever. Failures are therefore rebuilt from
// a fixed template plus err.code or the HTTP status — nothing else.
// __tests__/agent.test.js asserts no fragment of the token survives into
// the process output.
const os = require("node:os");
const http = require("node:http");
const https = require("node:https");
const path = require("node:path");
const DEFAULT_TIMEOUT_MS = 10000;
// Exit codes are the only signal cron has. 1 means "the install is wrong"
// (fix the env file), 2 means "the push did not land" (network or server).
const EXIT_CONFIG = 1;
const EXIT_PUSH = 2;
// Mirrors HOST_ID_PATTERN in index.js. Checked client-side so a typo in the env
// file fails with a readable message instead of an opaque 404 from the server.
const HOST_ID_RE = /^[a-z0-9][a-z0-9-]{0,31}$/;
// --- Payload ----------------------------------------------------------------
function pickUsage(value) {
return {
totalGB: value.totalGB,
usedGB: value.usedGB,
freeGB: value.freeGB,
usagePercent: value.usagePercent,
};
}
// Build the POST /hosts/<id> body: the GET /health payload minus `logto` and
// `timestamp`. Fields are copied one by one rather than spread, so a future
// addition to collectMetrics() cannot silently widen what leaves the
// workstation. `uptime` is floored exactly like getHealth() does.
function buildSnapshot(metrics) {
return {
hostname: os.hostname(),
uptime: Math.floor(os.uptime()),
cpu: {
model: metrics.cpu.model,
cores: metrics.cpu.cores,
loadAvg: metrics.cpu.loadAvg,
usagePercent: metrics.cpu.usagePercent,
},
memory: pickUsage(metrics.memory),
disk: pickUsage(metrics.disk),
};
}
// --- Failure messages -------------------------------------------------------
//
// The ONLY three places a failure string is built. Keep it that way: every
// caller below reports through one of these, none of them accepts an error
// object, and none of them is given the token or the request options.
function configFailure(reason) {
return new Error(`configuration error: ${reason}`);
}
function transportFailure(code) {
return new Error(`push failed: request error (code=${code || "UNKNOWN"})`);
}
function httpFailure(statusCode) {
return new Error(`push failed: HTTP ${statusCode}`);
}
// --- Config -----------------------------------------------------------------
function readConfig(env) {
const apiUrl = (env.HOSTS_API_URL || "").trim();
const token = env.HOSTS_INGEST_TOKEN || "";
const hostId = (env.HOST_ID || "").trim();
if (!apiUrl) return { error: configFailure("HOSTS_API_URL is not set") };
if (!token) return { error: configFailure("HOSTS_INGEST_TOKEN is not set") };
if (!hostId) return { error: configFailure("HOST_ID is not set") };
if (!HOST_ID_RE.test(hostId)) {
return {
error: configFailure("HOST_ID must match ^[a-z0-9][a-z0-9-]{0,31}$"),
};
}
// Shape-check the URL here too, so a broken env file exits 1 ("fix the
// install") rather than 2 ("the network is having a bad day"). postSnapshot
// repeats the check because it is also called directly by the tests.
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
return { error: configFailure("HOSTS_API_URL is not a valid URL") };
}
if (target.protocol !== "http:" && target.protocol !== "https:") {
return { error: configFailure("HOSTS_API_URL must be http:// or https://") };
}
const timeoutRaw = parseInt(env.HOST_AGENT_TIMEOUT_MS || "", 10);
const timeoutMs =
Number.isFinite(timeoutRaw) && timeoutRaw > 0 ? timeoutRaw : DEFAULT_TIMEOUT_MS;
return { config: { apiUrl, token, hostId, timeoutMs } };
}
// --- Delivery ---------------------------------------------------------------
function buildTargetUrl(apiUrl, hostId) {
const base = String(apiUrl).replace(/\/+$/, "");
return new URL(`${base}/hosts/${hostId}`);
}
// One attempt, hard-bounded in wall-clock time. Resolves to { statusCode } on
// 2xx, rejects with a template message otherwise. No retry lives here or above.
function postSnapshot({ apiUrl, token, hostId, snapshot, timeoutMs = DEFAULT_TIMEOUT_MS }) {
return new Promise((resolve, reject) => {
let target;
try {
target = buildTargetUrl(apiUrl, hostId);
} catch {
reject(configFailure("HOSTS_API_URL is not a valid URL"));
return;
}
const client =
target.protocol === "https:" ? https : target.protocol === "http:" ? http : null;
if (!client) {
reject(configFailure("HOSTS_API_URL must be http:// or https://"));
return;
}
const body = Buffer.from(JSON.stringify(snapshot), "utf-8");
let settled = false;
let req = null;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(arg);
};
// Wall-clock ceiling over the whole exchange: DNS, connect, TLS, response.
// The `timeout` request option only covers socket inactivity, which can add
// up past the budget on a flaky link. destroy() surfaces as an 'error' with
// the code below, so the timeout reports as ETIMEDOUT and nothing else.
const timer = setTimeout(() => {
if (settled || !req) return;
req.destroy(Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }));
}, timeoutMs);
try {
req = client.request(
target,
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": body.length,
Authorization: `Bearer ${token}`,
},
},
(res) => {
const { statusCode } = res;
// Drain without reading: the response body is never inspected and
// never logged. A 400 says the payload was rejected; the reason is in
// the server's logs, where it can be read without a token nearby.
res.resume();
res.on("end", () => {
if (statusCode >= 200 && statusCode < 300) {
finish(resolve, { statusCode });
} else {
finish(reject, httpFailure(statusCode));
}
});
res.on("error", (err) => finish(reject, transportFailure(err && err.code)));
},
);
} catch (err) {
// request() throws synchronously on an unusable header — in practice a
// token with a trailing newline, the classic env-file mistake. Node's
// ERR_INVALID_CHAR names the header but never quotes its value; it is
// still converted here rather than propagated, so no error object from
// the HTTP stack can ever reach a console call.
if (err && err.code === "ERR_INVALID_CHAR") {
finish(
reject,
configFailure(
"HOSTS_INGEST_TOKEN contains an invalid character (a trailing newline in the env file is the usual cause)",
),
);
} else {
finish(reject, transportFailure(err && err.code));
}
return;
}
// err.code and NOTHING else. err.message can carry the resolved address,
// and the error object dragged into a console call would carry the whole
// request — headers included.
req.on("error", (err) => finish(reject, transportFailure(err && err.code)));
req.end(body);
});
}
// --- Entry point ------------------------------------------------------------
// metrics.js is shared with the server and lives one directory up. The frozen
// install copy must keep that layout (see README) — when it does not, say so in
// one line rather than let cron mail a MODULE_NOT_FOUND stack.
function loadCollectMetrics() {
try {
return require(path.join(__dirname, "..", "metrics.js")).collectMetrics;
} catch (err) {
if (err && err.code === "MODULE_NOT_FOUND") {
throw configFailure(
"metrics.js not found one level above the agent — the install copy must keep the repo layout (see agent/README.md)",
);
}
throw err;
}
}
async function main(argv = process.argv.slice(2), env = process.env) {
// Dry run: collect and print the payload, touch no network, need no config.
// This is what commissioning runs first, before the env file even exists.
const dryRun = argv.includes("--dry-run");
// Config first: the CPU sample below costs 500ms, and there is no point
// spending it to then discover HOST_ID is a typo.
let config = null;
if (!dryRun) {
const result = readConfig(env);
if (result.error) {
console.error(`host-agent: ${result.error.message}`);
return EXIT_CONFIG;
}
config = result.config;
}
let snapshot;
try {
const collectMetrics = loadCollectMetrics();
snapshot = buildSnapshot(await collectMetrics());
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_CONFIG;
}
if (dryRun) {
console.log(JSON.stringify(snapshot, null, 2));
return 0;
}
try {
const { statusCode } = await postSnapshot({ ...config, snapshot });
console.log(
`host-agent: ok id=${config.hostId} status=${statusCode} hostname=${snapshot.hostname}`,
);
return 0;
} catch (err) {
console.error(`host-agent: ${err.message}`);
return EXIT_PUSH;
}
}
if (require.main === module) {
main().then(
(code) => {
process.exitCode = code;
},
(err) => {
// Nothing should reach here — every path above is handled. Print the
// message only, never the error object: a stack rendered by console.error
// is fine, but an object argument is what leaks headers.
console.error(`host-agent: unexpected failure: ${err && err.message}`);
process.exitCode = EXIT_PUSH;
},
);
}
module.exports = {
buildSnapshot,
buildTargetUrl,
postSnapshot,
readConfig,
main,
DEFAULT_TIMEOUT_MS,
EXIT_CONFIG,
EXIT_PUSH,
HOST_ID_RE,
};