Files
tera/scripts/check-zero-config-boot.mjs
T
Karti Tripathi 44c5a79424
gates / clean-clone (push) Successful in 15s
gates / zero-config-boot (push) Successful in 9s
gates / no-binary-art (push) Successful in 4s
SoCal, the whole bay, a moon, and gates that actually run
Six agents in parallel, and the two city packs independently reported the same
blocker: `focusRegions` and `coarseFactor` existed on the `City` type and
nothing implemented them. Uniform lattices would have been 2.9M points for
Southern California and 3.7M for the expanded bay. Both packs were unloadable
as written.

`buildAxis` is the answer, and it is honest about its limits: refinement is per
axis, not per rectangle, so a focus region sharpens its whole row *and* its
whole column. Two regions at opposite corners refine nearly everything between
them. Measured, not guessed — the bay went 0.53M points with one region and
1.64M with three, for detail nobody is looking at from a board this wide. One
region each, coarse factor ten, and the builds land at 3.8 s and 2.3 s.

Then three things that were only ever right because San Francisco was the only
city. `maxDistance: 340` and a 170-unit shadow box were constants tuned for a
230-unit board; the bay is 1003 units across and the camera physically could
not retreat far enough to frame it. Fog distances were scene units pinned to
the same assumption. And `minVisibilityM` defaulted to 4.5 km of honest
weather, which over ninety-four kilometres of bay correctly hides three
quarters of it — the night view was a black rectangle for a completely
reasonable reason. All three now derive from the board.

The moon is a real ephemeris and its light is a deliberate lie: 1.15, against a
physical ratio of one to four hundred thousand. What is being reproduced is
what a moonlit night looks like on a screen in a lit room.

The CI gate caught itself, which is the part worth keeping. Port 8431 was
already held by a server from an earlier session, so the boot check polled a
healthy stranger while the process it started died on EADDRINUSE. It now
refuses to run rather than pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 03:13:32 -07:00

347 lines
13 KiB
JavaScript

#!/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());