Files
Hide_D 6461ee5773 feat(runtime): 정적 프런트엔드와 격리 빌더를 구성한다
Caddy가 불변 아티팩트를 직접 제공하고 runtime과 builder의 자원·비밀 경계를 분리한다.

Gateway active release ref를 재기동에 복원하고 production/development/smoke 모델 검증을 확장한다.
2026-08-22 09:32:54 +00:00

183 lines
6.5 KiB
JavaScript

import { spawn } from 'node:child_process';
import http from 'node:http';
import path from 'node:path';
const port = Number(process.env.RELEASE_BUILDER_PORT ?? 15100);
const allowedRoots = [
'/srv/core/repository',
'/srv/core/profile-worktrees',
'/srv/core/release-worktrees',
'/workspace/core2026',
].map((root) => path.resolve(root));
const allowedEnvName = /^(?:CI|NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/;
const maxOutput = 64 * 1024;
let queue = Promise.resolve();
const appendTail = (current, chunk) => `${current}${String(chunk)}`.slice(-maxOutput);
const isWithin = (candidate, root) => {
const relative = path.relative(root, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const validateCommand = (raw) => {
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.args) || typeof raw.cwd !== 'string') {
throw new Error('Each release build command must provide command, args, and cwd.');
}
const cwd = path.resolve(raw.cwd);
if (!allowedRoots.some((root) => isWithin(cwd, root))) {
throw new Error(`Release build cwd is outside the shared Core workspaces: ${cwd}`);
}
const args = raw.args.map((value) => String(value));
if (raw.command === 'pnpm') {
const install = args[0] === 'install' && args.includes('--frozen-lockfile');
const turbo = args[0] === 'exec' && args[1] === 'turbo' && args[2] === 'run';
const packageTask = args[0] === '--filter' && args.length >= 3 && ['build', 'prisma:generate'].includes(args[2]);
if (!install && !turbo && !packageTask) throw new Error('Unsupported pnpm release build command.');
} else if (raw.command === 'node') {
if (args[0] !== 'tools/build-scripts/materialize-profile-frontend.mjs' || args.length !== 2) {
throw new Error('Unsupported Node release build command.');
}
} else {
throw new Error(`Unsupported release build executable: ${raw.command}`);
}
const overrides = Object.fromEntries(
Object.entries(raw.env ?? {}).filter(
([name, value]) => allowedEnvName.test(name) && typeof value === 'string',
),
);
return {
command: raw.command,
args,
cwd,
env: { ...process.env, ...overrides },
eventCommand: { command: raw.command, args, cwd, env: overrides },
};
};
const terminate = (child, signal) => {
if (!child.pid) return;
try {
process.kill(-child.pid, signal);
} catch {
try {
child.kill(signal);
} catch {
// The build already exited.
}
}
};
const runCommand = (command, emit, signal) =>
new Promise((resolve) => {
emit({ event: { type: 'COMMAND_START', command: command.eventCommand } });
const child = spawn(command.command, command.args, {
cwd: command.cwd,
env: command.env,
stdio: ['ignore', 'pipe', 'pipe'],
detached: true,
});
let output = '';
let aborted = false;
let spawnError = null;
const abort = () => {
if (aborted) return;
aborted = true;
terminate(child, 'SIGTERM');
const killTimer = setTimeout(() => terminate(child, 'SIGKILL'), 5000);
killTimer.unref();
};
signal.addEventListener('abort', abort, { once: true });
for (const stream of ['stdout', 'stderr']) {
child[stream].on('data', (chunk) => {
output = appendTail(output, chunk);
for (const message of String(chunk).split(/\r?\n/).filter(Boolean)) {
emit({ event: { type: 'OUTPUT', stream, message: message.slice(0, 2000) } });
}
});
}
child.on('error', (error) => {
spawnError = error;
output = appendTail(output, error.message);
});
child.on('close', (exitCode) => {
signal.removeEventListener('abort', abort);
emit({ event: { type: 'COMMAND_END', command: command.eventCommand, exitCode: spawnError ? null : exitCode } });
resolve({
ok: !aborted && !spawnError && exitCode === 0,
exitCode: spawnError ? null : exitCode,
output,
...(aborted ? { aborted: true } : {}),
});
});
});
const runJob = async (commands, emit, signal) => {
let output = '';
for (const command of commands) {
if (signal.aborted) return { ok: false, exitCode: null, output, aborted: true };
const result = await runCommand(command, emit, signal);
output = appendTail(output, result.output);
if (!result.ok) return { ...result, output };
}
return { ok: true, exitCode: 0, output };
};
const readJson = async (request) => {
let body = '';
for await (const chunk of request) {
body += chunk;
if (body.length > 256 * 1024) throw new Error('Release build request is too large.');
}
return JSON.parse(body);
};
const server = http.createServer(async (request, response) => {
if (request.method === 'GET' && request.url === '/healthz') {
response.writeHead(200, { 'content-type': 'text/plain' });
response.end('ok');
return;
}
if (request.method !== 'POST' || request.url !== '/v1/builds') {
response.writeHead(404).end();
return;
}
try {
const payload = await readJson(request);
if (!Array.isArray(payload.commands) || payload.commands.length === 0 || payload.commands.length > 12) {
throw new Error('Release build request must contain 1-12 commands.');
}
const commands = payload.commands.map(validateCommand);
response.writeHead(200, {
'content-type': 'application/x-ndjson',
'cache-control': 'no-store',
'x-content-type-options': 'nosniff',
});
response.flushHeaders();
const abortController = new AbortController();
request.once('aborted', () => abortController.abort());
response.once('close', () => {
if (!response.writableEnded) abortController.abort();
});
const emit = (message) => {
if (!response.writableEnded) response.write(`${JSON.stringify(message)}\n`);
};
const execution = queue.then(() => runJob(commands, emit, abortController.signal));
queue = execution.then(() => undefined, () => undefined);
const result = await execution;
emit({ result });
response.end();
} catch (error) {
if (!response.headersSent) response.writeHead(400, { 'content-type': 'text/plain' });
response.end(error instanceof Error ? error.message : String(error));
}
});
server.listen(port, '0.0.0.0', () => {
console.log(`[release-builder] listening on ${port}`);
});
const shutdown = () => server.close(() => process.exit(0));
process.once('SIGINT', shutdown);
process.once('SIGTERM', shutdown);