342 lines
9.9 KiB
JavaScript
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);
|
|
}
|