Files
core2026_docker/runtime/release-builder.mjs

342 lines
9.9 KiB
JavaScript

import { spawn } from "node:child_process";
import { createHash, timingSafeEqual } from "node:crypto";
import http from "node:http";
import { fileURLToPath } from "node:url";
import path from "node:path";
import {
BUILD_TIMEOUT_MS,
MAX_BUILD_FRAME_BYTES,
readBuilderToken,
validateBuildCommand,
} from "./release-builder-security.mjs";
const roots = [
"/srv/core/repository",
"/srv/core/profile-worktrees",
"/srv/core/release-worktrees",
"/workspace/core2026",
];
const caches = [...roots, "/srv/builder-cache"];
const appendTail = (current, chunk) =>
`${current}${String(chunk)}`.slice(-64 * 1024);
const digest = (value) => createHash("sha256").update(value).digest();
const terminate = (child, signal) => {
if (!child.pid) return;
try {
process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
/* Already exited. */
}
}
};
const readJson = (request, timeoutMs) =>
new Promise((resolve, reject) => {
const body = Buffer.alloc(256 * 1024);
let size = 0;
const finish = (error, value) => {
clearTimeout(timer);
request
.off("data", data)
.off("end", end)
.off("error", fail)
.off("aborted", fail);
if (error) reject(error);
else resolve(value);
};
const fail = () =>
finish(
Object.assign(new Error("Build request interrupted."), { status: 400 }),
);
const data = (chunk) => {
if (size + chunk.length > body.length) {
request.pause();
finish(
Object.assign(new Error("Build request is too large."), {
status: 413,
}),
);
return;
}
chunk.copy(body, size);
size += chunk.length;
};
const end = () => {
try {
finish(
null,
JSON.parse(
new TextDecoder("utf-8", { fatal: true }).decode(
body.subarray(0, size),
),
),
);
} catch {
finish(
Object.assign(new Error("Invalid build JSON."), { status: 400 }),
);
}
};
const timer = setTimeout(() => {
request.pause();
finish(
Object.assign(new Error("Build request timed out."), { status: 408 }),
);
}, timeoutMs);
timer.unref();
request
.on("data", data)
.once("end", end)
.once("error", fail)
.once("aborted", fail);
});
export const createReleaseBuilderServer = ({
token,
allowedRoots = roots,
cacheRoots = caches,
spawnImpl = spawn,
maxJobs = 3,
jobTimeoutMs = BUILD_TIMEOUT_MS,
bodyTimeoutMs = 5000,
terminateGraceMs = 5000,
baseEnv = process.env,
}) => {
if (!/^[A-Za-z0-9_-]{43,128}$/.test(token ?? ""))
throw new Error("A valid builder token is required.");
const tokenDigest = digest(token);
// Repository scripts are trusted release inputs; unrelated process secrets are excluded.
const childBaseEnv = Object.fromEntries(
Object.entries(baseEnv).filter(
([name, value]) =>
typeof value === "string" &&
[
"PATH",
"HOME",
"LANG",
"TZ",
"CI",
"NODE_OPTIONS",
"PROFILE_FRONTEND_BUILD_NODE_OPTIONS",
"RAYON_NUM_THREADS",
"RELEASE_TURBO_CONCURRENCY",
"TURBO_CACHE_DIR",
"XDG_DATA_HOME",
].includes(name),
),
);
let jobs = 0;
let queue = Promise.resolve();
const controllers = new Set();
const children = new Set();
const runCommand = (command, emit, signal) =>
new Promise((resolve) => {
if (signal.aborted) {
resolve({ ok: false, exitCode: null, output: "", aborted: true });
return;
}
emit({ event: { type: "COMMAND_START", command } });
if (signal.aborted) {
resolve({ ok: false, exitCode: null, output: "", aborted: true });
return;
}
const child = spawnImpl(command.command, command.args, {
cwd: command.cwd,
env: { ...childBaseEnv, ...command.env },
stdio: ["ignore", "pipe", "pipe"],
detached: true,
});
children.add(child);
let output = "";
let failed = false;
let killTimer;
const abort = () => {
terminate(child, "SIGTERM");
killTimer = setTimeout(
() => terminate(child, "SIGKILL"),
terminateGraceMs,
);
killTimer.unref();
};
signal.addEventListener("abort", abort, { once: true });
if (signal.aborted) abort();
for (const stream of ["stdout", "stderr"])
child[stream].on("data", (chunk) => {
if (signal.aborted) return;
output = appendTail(output, chunk);
const text = String(chunk);
for (let offset = 0; offset < text.length; offset += 2000)
emit({
event: {
type: "OUTPUT",
stream,
message: text.slice(offset, offset + 2000),
},
});
});
child.once("error", () => {
failed = true;
output = appendTail(output, "Release build process failed.");
});
child.once("close", (exitCode) => {
children.delete(child);
signal.removeEventListener("abort", abort);
clearTimeout(killTimer);
emit({
event: {
type: "COMMAND_END",
command,
exitCode: failed ? null : exitCode,
},
});
resolve({
ok: !signal.aborted && !failed && exitCode === 0,
exitCode: failed ? null : exitCode,
output,
...(signal.aborted ? { aborted: true } : {}),
});
});
});
const server = http.createServer(async (request, response) => {
request.on("error", () => {});
const reject = (status) => {
response.writeHead(status, {
"content-type": "text/plain",
connection: "close",
...(status === 429 ? { "retry-after": "1" } : {}),
});
response.end(`Release builder request rejected (${status}).`);
};
if (request.method === "GET" && request.url === "/healthz") {
response.writeHead(200, { "content-type": "text/plain" }).end("ok");
return;
}
if (request.method !== "POST" || request.url !== "/v1/builds") {
reject(404);
return;
}
const presented = request.headers["x-release-builder-token"];
if (
typeof presented !== "string" ||
!/^[A-Za-z0-9_-]{43,128}$/.test(presented) ||
!timingSafeEqual(digest(presented), tokenDigest)
) {
reject(401);
return;
}
if (jobs >= maxJobs) {
reject(429);
return;
}
jobs += 1;
const controller = new AbortController();
controllers.add(controller);
const deadline = setTimeout(() => controller.abort(), jobTimeoutMs);
deadline.unref();
response.once("close", () => {
if (!response.writableEnded) controller.abort();
});
try {
const payload = await readJson(request, bodyTimeoutMs);
if (
!payload ||
!Array.isArray(payload.commands) ||
payload.commands.length < 1 ||
payload.commands.length > 12
)
throw new Error("Invalid command list.");
const commands = payload.commands.map((raw) =>
validateBuildCommand(raw, allowedRoots, cacheRoots),
);
response.writeHead(200, {
"content-type": "application/x-ndjson",
"cache-control": "no-store",
"x-content-type-options": "nosniff",
});
response.flushHeaders();
let streamedBytes = 0;
const emit = (message) => {
if (
response.destroyed ||
response.writableEnded ||
controller.signal.aborted
)
return;
const line = `${JSON.stringify(message)}\n`;
streamedBytes += Buffer.byteLength(line);
if (
streamedBytes > 64 * 1024 * 1024 ||
Buffer.byteLength(line) > MAX_BUILD_FRAME_BYTES ||
response.writableLength + Buffer.byteLength(line) > 1024 * 1024
) {
controller.abort();
response.destroy();
return;
}
response.write(line);
};
const execute = async () => {
let output = "";
for (const command of commands) {
const result = await runCommand(command, emit, controller.signal);
output = appendTail(output, result.output);
if (!result.ok) return { ...result, output };
}
return { ok: true, exitCode: 0, output };
};
const execution = queue.then(execute);
queue = execution.then(
() => undefined,
() => undefined,
);
const result = await execution;
if (controller.signal.aborted && !response.destroyed)
response.end(`${JSON.stringify({ result })}\n`);
else {
emit({ result });
response.end();
}
} catch (error) {
controller.abort();
if (!response.headersSent) reject(error?.status ?? 400);
else response.destroy();
} finally {
clearTimeout(deadline);
controllers.delete(controller);
jobs -= 1;
}
});
server.maxConnections = 64;
server.maxRequestsPerSocket = 20;
server.headersTimeout = 10000;
server.requestTimeout = 30000;
server.keepAliveTimeout = 5000;
server.on("close", () => {
for (const controller of controllers) controller.abort();
for (const child of children) terminate(child, "SIGKILL");
});
return server;
};
if (
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const server = createReleaseBuilderServer({
token: readBuilderToken(process.env.RELEASE_BUILDER_TOKEN_FILE),
});
const port = Number(process.env.RELEASE_BUILDER_PORT ?? 15100);
server.listen(port, "0.0.0.0", () =>
console.log(`[release-builder] listening on ${port}`),
);
const shutdown = () => {
server.closeAllConnections();
server.close(() => process.exit(0));
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
}