#!/usr/bin/env node /** * The zero-config boot gate: hand the API nothing at all and it still answers. * * This exists because two independent server designs made the same mistake — * defaulting the weather source to a provider that requires a contact string, * then failing hard when the contact was absent — which breaks the one * acceptance test the whole repo is built around: a stranger clones this, runs * one command, and gets a working box with no account, no key and no network. * CONTRACT.md §5.1 resolves it (a source configured without what it needs is * demoted, not fatal) and this script is what keeps the resolution honest. * * The environment handed to the server is genuinely empty — `env: {}`, the * in-process equivalent of `env -i` — which is possible only because the child * is launched by absolute path (`process.execPath`) and so needs no PATH to find * itself. Nothing is stubbed and no config object is constructed by hand: this * starts the real entry point and asks the real socket, which is the difference * between this and `server/src/test/boot.test.ts`, which asserts the same * property one layer down. * * node scripts/check-zero-config-boot.mjs * node scripts/check-zero-config-boot.mjs --compose * * The second form is CONTRACT.md §0's literal wording — `docker compose up` under * an empty environment — and needs a working Docker. CI runs the first form, * because a gate that depends on docker-in-docker being present on the runner is * measuring the runner rather than the repo. The two assert the same thing: the * compose file adds only `TERA_HOST` and container hardening, and every other * variable in it carries a `:-` default precisely so that an empty environment * resolves it to the empty string. */ import { spawn } from "node:child_process"; import { connect } from "node:net"; import { fileURLToPath } from "node:url"; // ---- Where and what ---- const REPO_ROOT = fileURLToPath(new URL("..", import.meta.url)); const HEALTH_HOST = "127.0.0.1"; const HEALTH_PORT = 8431; const HEALTH_URL = `http://${HEALTH_HOST}:${HEALTH_PORT}/api/v1/health`; /** How long the server gets to bind and answer before this gives up. */ const PROCESS_DEADLINE_MS = 20_000; /** Compose has to build an image first, which is a different order of patience. */ const COMPOSE_DEADLINE_MS = 300_000; const useCompose = process.argv.slice(2).includes("--compose"); // ---- Reporting ---- function fail(headline, detail = [], log = []) { console.error(`\ncheck-zero-config-boot: FAIL — ${headline}\n`); for (const line of detail) console.error(line); if (log.length > 0) { console.error("\n--- what the server said ---"); console.error(log.join("").trimEnd()); console.error("--- end ---"); } console.error(""); process.exitCode = 1; } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); // ---- Making sure we are testing our own server ---- /** * Is anything already listening on the port we are about to claim? * * This guard is here because the check silently passed without it. The port was * occupied by an unrelated process, the server we spawned died on `EADDRINUSE` * within a second, and the poll cheerfully collected a 200 from the stranger — * a green gate asserting nothing at all. A check that can pass against a server * it did not start is worse than no check, so an occupied port is a hard stop. */ function portInUse() { return new Promise((resolve) => { const socket = connect({ host: HEALTH_HOST, port: HEALTH_PORT }); const settle = (answer) => { socket.destroy(); resolve(answer); }; socket.setTimeout(1_500); socket.once("connect", () => settle(true)); socket.once("timeout", () => settle(false)); socket.once("error", () => settle(false)); }); } async function assertPortFree() { if (!(await portInUse())) return true; fail(`something is already listening on ${HEALTH_HOST}:${HEALTH_PORT}.`, [ "This check has to own that port, because otherwise it polls whatever is there and", "reports a healthy stranger while the server it started is dead in a ditch. It cannot", "move to another port either: choosing one would mean setting TERA_PORT, and the empty", "environment is the thing under test.", "", "Stop whatever is on the port and run it again.", ]); return false; } /** * A second line of defence against answering for somebody else's process: if the * server on the other end has been up longer than this check has been running, * it is not the one we started. */ function uptimeLooksLikeOurs(body, startedAt) { const allowed = Math.ceil((Date.now() - startedAt) / 1000) + 2; return typeof body.uptimeSeconds !== "number" || body.uptimeSeconds <= allowed; } // ---- The assertion ---- /** * Poll until health answers or the deadline passes. `stillRunning` lets the * caller abort early when the thing under test has already died, so a server * that exits on boot reports its own error instead of a twenty-second timeout. */ async function waitForHealth(deadlineMs, stillRunning) { const deadline = Date.now() + deadlineMs; let lastError = "nothing was listening"; while (Date.now() < deadline) { if (!stillRunning()) return { dead: true, lastError }; try { const response = await fetch(HEALTH_URL, { signal: AbortSignal.timeout(2_000), }); const text = await response.text(); return { status: response.status, text }; } catch (err) { lastError = err instanceof Error ? err.message : String(err); } await sleep(250); } return { timedOut: true, lastError }; } /** * Everything the health body has to say on a box that was handed nothing. * * The source and mode checks are not padding. A default that needs configuration * is exactly the bug this gate was written for, and it would still return 200 * while being wrong — the failure showed up as an empty sky, not as a dead * process. `degraded` being non-empty means the config layer demoted something, * which on an empty environment means a default asked for something it was never * going to be given. */ function checkPosture(body) { const problems = []; if (body.ok !== true) { problems.push(` health reported ok: ${JSON.stringify(body.ok)}, expected true`); } if (body.sources?.weather !== "none") { problems.push( ` weather source defaulted to ${JSON.stringify(body.sources?.weather)}; CONTRACT.md §5.1`, ` requires "none", because every other source wants a contact string or a key.`, ); } if (body.auth?.mode !== "none") { problems.push( ` auth mode defaulted to ${JSON.stringify(body.auth?.mode)}; CONTRACT.md §6 requires "none"`, ` so that a self-hoster never creates an account anywhere.`, ); } if (!Array.isArray(body.degraded)) { problems.push(` health body has no degraded array; it is how demotions become visible.`); } else if (body.degraded.length > 0) { problems.push( ` the config layer demoted ${body.degraded.length} source(s) on an empty environment:`, ...body.degraded.map((line) => ` ${line}`), ` A demotion here means a default was chosen that needs configuration to work.`, ); } return problems; } function report(body) { console.log("check-zero-config-boot: ok — 200 from /api/v1/health on an empty environment."); console.log(` service: ${body.service} ${body.version}`); console.log( ` sources: weather=${body.sources?.weather} flights=${body.sources?.flights} ` + `markers=${body.sources?.markers}`, ); console.log(` auth: ${body.auth?.mode}`); console.log(` degraded: none`); } /** Shared tail of both modes: parse, assert, print. */ function finish(result, log, startedAt) { if (result.dead) { fail( "the server exited before it ever answered.", [ "It was started with a genuinely empty environment, which is the whole point: a box", "that needs a variable set before it will boot is not self-hostable. See CONTRACT.md §5.1.", ` last connection attempt: ${result.lastError}`, ], log, ); return; } if (result.timedOut) { fail( `nothing answered ${HEALTH_URL} before the deadline.`, [` last connection attempt: ${result.lastError}`], log, ); return; } if (result.status !== 200) { fail( `${HEALTH_URL} returned ${result.status}, expected 200.`, [ "Health touches no upstream and reads no file by design, so a non-200 here is the", "server refusing to be healthy without configuration it should not need.", ` body: ${result.text.slice(0, 500)}`, ], log, ); return; } let body; try { body = JSON.parse(result.text); } catch { fail("health answered 200 but the body was not JSON.", [` body: ${result.text.slice(0, 500)}`], log); return; } if (!uptimeLooksLikeOurs(body, startedAt)) { fail( `${HEALTH_URL} answered, but from a server this check did not start.`, [ ` it reports ${body.uptimeSeconds}s of uptime; this check has been running for`, ` ${Math.ceil((Date.now() - startedAt) / 1000)}s. Something else claimed the port first.`, ], log, ); return; } const problems = checkPosture(body); if (problems.length > 0) { fail("health answered 200, but not from a zero-config box.", problems, log); return; } report(body); } // ---- Mode: the real entry point under an empty environment ---- async function runProcessMode() { if (!(await assertPortFree())) return; console.log("check-zero-config-boot: starting server/src/index.ts with env -i (no variables at all)"); const startedAt = Date.now(); const child = spawn(process.execPath, ["server/src/index.ts"], { cwd: REPO_ROOT, // The empty environment is the test. `process.execPath` is absolute, so the // child needs no PATH to exist, and Node needs nothing else to run. env: {}, stdio: ["ignore", "pipe", "pipe"], }); const log = []; child.stdout.on("data", (chunk) => log.push(String(chunk))); child.stderr.on("data", (chunk) => log.push(String(chunk))); let alive = true; let exitInfo = ""; child.on("exit", (code, signal) => { alive = false; exitInfo = signal ? `killed by ${signal}` : `exited with code ${code}`; }); child.on("error", (err) => { alive = false; exitInfo = `could not spawn: ${err.message}`; }); try { const result = await waitForHealth(PROCESS_DEADLINE_MS, () => alive); if (result.dead) log.push(`\n(process ${exitInfo})\n`); finish(result, log, startedAt); } finally { if (alive) { child.kill("SIGTERM"); // The entry point closes on SIGTERM; if it does not, this is a check, not a // supervisor, and a lingering child would hang CI. for (let waited = 0; alive && waited < 5_000; waited += 100) await sleep(100); if (alive) child.kill("SIGKILL"); } } } // ---- Mode: docker compose, for whoever has a Docker ---- function docker(args, timeoutMs) { return new Promise((resolve) => { const child = spawn("docker", args, { cwd: REPO_ROOT, // PATH and HOME are for the Docker CLI itself — finding its binary and its // config — and reach the container through nothing. The compose file // interpolates only TERA_* variables, all with `:-` defaults, so the // container's own environment is empty either way. env: { PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", HOME: process.env.HOME ?? "/tmp" }, stdio: ["ignore", "pipe", "pipe"], timeout: timeoutMs, }); const out = []; child.stdout.on("data", (chunk) => out.push(String(chunk))); child.stderr.on("data", (chunk) => out.push(String(chunk))); child.on("close", (code) => resolve({ code, out: out.join("") })); child.on("error", (err) => resolve({ code: -1, out: `could not run docker: ${err.message}` })); }); } async function runComposeMode() { if (!(await assertPortFree())) return; const compose = ["compose", "-f", "deploy/docker-compose.yml"]; console.log("check-zero-config-boot: docker compose up, with no .env file and no TERA_* set"); const up = await docker([...compose, "up", "-d", "--build"], COMPOSE_DEADLINE_MS); if (up.code !== 0) { fail("`docker compose up` failed.", [" " + up.out.trim().split("\n").join("\n ")]); return; } // After the build, not before it: `up -d` returns with the container just // started, so the uptime it reports is measured from about here. const startedAt = Date.now(); try { const result = await waitForHealth(60_000, () => true); const logs = await docker([...compose, "logs", "--no-color"], 30_000); finish(result, [logs.out], startedAt); } finally { await docker([...compose, "down", "-v"], 60_000); } } // ---- Run ---- await (useCompose ? runComposeMode() : runProcessMode());