44c5a79424
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>
347 lines
13 KiB
JavaScript
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());
|