원격 릴리스 빌드에 토큰 파일 인증과 응답 제한을 적용한다

This commit is contained in:
2026-10-09 04:31:44 +09:00
parent 3d9f7392df
commit 6755f09ee7
9 changed files with 351 additions and 51 deletions
+151 -40
View File
@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { readFileSync, statSync } from 'node:fs';
import path from 'node:path';
export interface BuildCommand {
@@ -33,7 +34,8 @@ export interface BuildRunner {
export const MAX_BUILD_OUTPUT_CHARS = 64 * 1024;
const DEFAULT_RELEASE_TURBO_CONCURRENCY = 1;
const RELEASE_BUILD_ENV_NAME = /^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
const RELEASE_BUILD_ENV_NAME =
/^(?:CI|PATH|NODE_OPTIONS|RELEASE_BUILD_NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/u;
export const sanitizeReleaseBuildEnv = (
env: NodeJS.ProcessEnv | Record<string, string> | undefined
@@ -236,11 +238,100 @@ interface RemoteBuildMessage {
error?: string;
}
export const MAX_REMOTE_BUILD_FRAME_BYTES = 512 * 1024;
const REMOTE_BUILD_TIMEOUT_MS = 30 * 60 * 1000;
const record = (value: unknown): value is Record<string, unknown> =>
Boolean(value) && typeof value === 'object' && !Array.isArray(value);
const validRemoteMessage = (value: unknown): value is RemoteBuildMessage => {
if (!record(value)) return false;
if (Object.keys(value).length !== 1) return false;
if (value.result) {
const result = value.result;
return (
record(result) &&
typeof result.ok === 'boolean' &&
(!result.ok || (result.exitCode === 0 && result.aborted !== true)) &&
(result.exitCode === null || (typeof result.exitCode === 'number' && Number.isInteger(result.exitCode))) &&
typeof result.output === 'string' &&
result.output.length <= MAX_BUILD_OUTPUT_CHARS &&
(result.aborted === undefined || typeof result.aborted === 'boolean')
);
}
if (typeof value.error === 'string') return value.error.length <= 2000;
if (!record(value.event)) return false;
const event = value.event;
if (event.type === 'OUTPUT')
return (
typeof event.stream === 'string' &&
['stdout', 'stderr'].includes(event.stream) &&
typeof event.message === 'string' &&
event.message.length <= 2000
);
if (
typeof event.type !== 'string' ||
!['COMMAND_START', 'COMMAND_END'].includes(event.type) ||
!record(event.command)
)
return false;
const command = event.command;
if (
typeof command.command !== 'string' ||
!['pnpm', 'node'].includes(command.command) ||
typeof command.cwd !== 'string' ||
command.cwd.length > 2048 ||
!Array.isArray(command.args) ||
command.args.length > 40 ||
!command.args.every((arg: unknown) => typeof arg === 'string' && arg.length <= 2048)
)
return false;
if (
command.env !== undefined &&
(!record(command.env) ||
Object.keys(command.env).length > 32 ||
!Object.values(command.env).every((entry) => typeof entry === 'string' && entry.length <= 8192))
)
return false;
return (
event.type === 'COMMAND_START' ||
event.exitCode === null ||
(typeof event.exitCode === 'number' && Number.isInteger(event.exitCode))
);
};
const readRemoteBuildToken = (filename: string | undefined): string => {
try {
if (!filename || !statSync(filename).isFile() || statSync(filename).size > 256) throw new Error();
const token = readFileSync(filename, 'utf8').trim();
if (token.startsWith('replace-with-') || !/^[A-Za-z0-9_-]{43,128}$/.test(token)) throw new Error();
return token;
} catch {
throw new Error('A valid RELEASE_BUILDER_TOKEN_FILE is required.');
}
};
export interface RemoteBuildOptions {
tokenFile?: string;
timeoutMs?: number;
}
export class RemoteBuildRunner implements BuildRunner {
private readonly endpoint: string;
private readonly timeoutMs: number;
constructor(baseUrl: string, private readonly fetchImpl: typeof fetch = fetch) {
this.endpoint = new URL('/v1/builds', baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`).toString();
constructor(
baseUrl: string,
private readonly fetchImpl: typeof fetch = fetch,
private readonly config: RemoteBuildOptions = {}
) {
const parsed = new URL('/v1/builds', baseUrl);
if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password) {
throw new Error('Invalid release builder endpoint.');
}
this.endpoint = parsed.toString();
this.timeoutMs = config.timeoutMs ?? REMOTE_BUILD_TIMEOUT_MS;
if (!Number.isInteger(this.timeoutMs) || this.timeoutMs < 1 || this.timeoutMs > REMOTE_BUILD_TIMEOUT_MS) {
throw new Error('Invalid release builder deadline.');
}
}
async run(
@@ -249,73 +340,92 @@ export class RemoteBuildRunner implements BuildRunner {
options?: BuildRunOptions
): Promise<BuildResult> {
let output = '';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
timeout.unref();
const signal = options?.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
try {
const token = readRemoteBuildToken(this.config.tokenFile);
const response = await this.fetchImpl(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
redirect: 'error',
headers: { 'content-type': 'application/json', 'x-release-builder-token': token },
body: JSON.stringify({
commands: commands.map((command) => ({
...command,
env: sanitizeReleaseBuildEnv(command.env),
})),
commands: commands.map((command) => ({ ...command, env: sanitizeReleaseBuildEnv(command.env) })),
}),
signal: options?.signal,
signal,
});
if (!response.ok || !response.body) {
const detail = await response.text().catch(() => '');
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, detail || `Release builder returned HTTP ${response.status}.`),
};
await response.body?.cancel().catch(() => undefined);
return { ok: false, exitCode: null, output: `Release builder returned HTTP ${response.status}.` };
}
const decoder = new TextDecoder();
reader = response.body.getReader();
const decoder = new TextDecoder('utf-8', { fatal: true });
let buffer = '';
for await (const chunk of response.body) {
buffer += decoder.decode(chunk, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? '';
for (const line of lines) {
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') {
output = appendOutputTail(output, `${result.event.message}\n`);
}
if (result.result) return result.result;
if (result.error) {
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
let bytes = 0;
while (true) {
const chunk = await reader.read();
if (chunk.done) {
buffer += decoder.decode();
break;
}
bytes += chunk.value.byteLength;
if (bytes > 64 * 1024 * 1024) throw new Error('Build stream too large.');
buffer += decoder.decode(chunk.value, { stream: true });
let newline: number;
while ((newline = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
const result = await this.handleRemoteMessage(line, onProgress);
if (result.event?.type === 'OUTPUT') output = appendOutputTail(output, `${result.event.message}\n`);
if (result.result) return result.result;
if (result.error)
return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
if (Buffer.byteLength(buffer) > MAX_REMOTE_BUILD_FRAME_BYTES) throw new Error('Build frame too large.');
}
if (buffer.trim()) {
const result = await this.handleRemoteMessage(buffer, onProgress);
if (result.result) return result.result;
if (result.error) return { ok: false, exitCode: null, output: appendOutputTail(output, result.error) };
}
return { ok: false, exitCode: null, output: appendOutputTail(output, 'Release builder closed without a result.') };
} catch (error) {
return {
ok: false,
exitCode: null,
output: appendOutputTail(output, 'Release builder closed without a result.'),
};
} catch {
const aborted = options?.signal?.aborted ?? false;
return {
ok: false,
exitCode: null,
output: appendOutputTail(
output,
aborted ? 'Build cancelled by operator.' : error instanceof Error ? error.message : String(error)
aborted ? 'Build cancelled by operator.' : 'Release builder request failed.'
),
...(aborted ? { aborted: true } : {}),
};
} finally {
clearTimeout(timeout);
controller.abort();
await reader?.cancel().catch(() => undefined);
reader?.releaseLock();
}
}
private async handleRemoteMessage(
line: string,
onProgress?: BuildProgressObserver
): Promise<RemoteBuildMessage> {
let message: RemoteBuildMessage;
private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise<RemoteBuildMessage> {
if (!line.trim()) return {};
if (Buffer.byteLength(line) > MAX_REMOTE_BUILD_FRAME_BYTES)
return { error: 'Release builder frame is too large.' };
let message: unknown;
try {
message = JSON.parse(line) as RemoteBuildMessage;
message = JSON.parse(line);
} catch {
return { error: 'Release builder returned malformed progress data.' };
}
if (!validRemoteMessage(message)) return { error: 'Release builder returned invalid progress data.' };
if (message.error !== undefined) return { error: 'Release builder rejected the build request.' };
if (message.event && onProgress) await onProgress(message.event);
return message;
}
@@ -324,5 +434,6 @@ export class RemoteBuildRunner implements BuildRunner {
export const createReleaseBuildRunner = (
baseUrl: string | undefined,
localRunner: BuildRunner,
fetchImpl: typeof fetch = fetch
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl) : localRunner);
fetchImpl: typeof fetch = fetch,
tokenFile?: string
): BuildRunner => (baseUrl?.trim() ? new RemoteBuildRunner(baseUrl.trim(), fetchImpl, { tokenFile }) : localRunner);
@@ -1023,7 +1023,8 @@ export class GatewayOrchestrator implements GatewayOrchestratorHandle {
this.releaseBuildRunner = createReleaseBuildRunner(
options.processConfig.releaseBuilderUrl,
options.buildRunner,
options.fetchImpl ?? fetch
options.fetchImpl ?? fetch,
options.processConfig.baseEnv?.RELEASE_BUILDER_TOKEN_FILE
);
this.workspaceManager = options.workspaceManager;
this.processConfig = options.processConfig;
+17 -2
View File
@@ -1,4 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import {
RemoteBuildRunner,
@@ -34,12 +37,24 @@ describe('sanitizeReleaseBuildEnv', () => {
});
describe('RemoteBuildRunner', () => {
let directory: string;
let tokenFile: string;
beforeAll(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'builder-token-test-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterAll(async () => {
await fs.rm(directory, { recursive: true, force: true });
});
it('streams progress and returns the builder result without sending secrets', async () => {
const messages = [
{ event: { type: 'OUTPUT', stream: 'stdout', message: 'building' } },
{ result: { ok: true, exitCode: 0, output: 'building' } },
];
const fetchImpl = vi.fn(async (_url: string | URL | Request, init?: RequestInit) => {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
expect(init?.redirect).toBe('error');
const request = JSON.parse(String(init?.body)) as {
commands: Array<{ env: Record<string, string> }>;
};
@@ -49,7 +64,7 @@ describe('RemoteBuildRunner', () => {
});
}) as unknown as typeof fetch;
const progress: BuildProgressEvent[] = [];
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl).run(
const result = await new RemoteBuildRunner('http://builder:15100', fetchImpl, { tokenFile }).run(
[
{
command: 'pnpm',
@@ -0,0 +1,114 @@
import { createServer, type Server, type ServerResponse, type IncomingMessage } from 'node:http';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { RemoteBuildRunner } from '../src/orchestrator/buildRunner.js';
describe('release builder authenticated HTTP and bounded responses', () => {
let directory: string;
let tokenFile: string;
const servers: Server[] = [];
beforeEach(async () => {
directory = await fs.mkdtemp(path.join(os.tmpdir(), 'remote-build-http-'));
tokenFile = path.join(directory, 'token');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
});
afterEach(async () => {
for (const server of servers.splice(0)) {
server.closeAllConnections();
await new Promise<void>((resolve) => server.close(() => resolve()));
}
await fs.rm(directory, { recursive: true, force: true });
});
const provider = async (handler: (req: IncomingMessage, res: ServerResponse) => void) => {
const server = createServer(handler);
servers.push(server);
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') throw new Error('Fixture listener unavailable.');
return `http://127.0.0.1:${address.port}`;
};
const commands = [{ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: '/srv/core/repository' }];
it('sends the file token only as a header and preserves successful stream results', async () => {
let received = '';
const endpoint = await provider((req, res) => {
expect(req.headers['x-release-builder-token']).toBe('t'.repeat(64));
req.on('data', (chunk: Buffer) => {
received += chunk.toString();
});
req.on('end', () => res.end(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: 'done' } })}\n`));
});
expect(await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).toMatchObject({
ok: true,
output: 'done',
});
expect(received).not.toContain('t'.repeat(64));
});
it('fails closed without a valid credential before sending a request', async () => {
let requests = 0;
const endpoint = await provider((_req, res) => {
requests += 1;
res.end();
});
const result = await new RemoteBuildRunner(endpoint).run(commands);
expect(result.ok).toBe(false);
expect(requests).toBe(0);
expect(result.output).not.toContain(directory);
});
it('rejects malformed and oversized progress without exposing upstream error payloads', async () => {
for (const body of [
'{"event":{"type":"OUTPUT","stream":"stdout","message":42}}\n',
'x'.repeat(512 * 1024 + 1),
'null\n',
'{"result":{"ok":true,"exitCode":2,"output":""}}\n',
]) {
const endpoint = await provider((_req, res) => res.end(body));
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
}
const endpoint = await provider((_req, res) => {
res.writeHead(500);
res.end('synthetic-sensitive-detail'.repeat(100_000));
});
const result = await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands);
expect(result.output).toBe('Release builder returned HTTP 500.');
const rejected = await provider((_req, res) =>
res.end(JSON.stringify({ error: 'synthetic-sensitive-detail' }) + '\n')
);
expect((await new RemoteBuildRunner(rejected, fetch, { tokenFile }).run(commands)).output).toBe(
'Release builder rejected the build request.'
);
});
it('does not forward the token to redirect destinations', async () => {
let requests = 0;
const destination = await provider((_req, res) => {
requests += 1;
res.end();
});
const endpoint = await provider((_req, res) => {
res.writeHead(307, { location: destination });
res.end();
});
expect((await new RemoteBuildRunner(endpoint, fetch, { tokenFile }).run(commands)).ok).toBe(false);
expect(requests).toBe(0);
});
it('bounds header/body stalls and preserves explicit operator cancellation', async () => {
const silent = await provider(() => {});
expect((await new RemoteBuildRunner(silent, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const partial = await provider((_req, res) => {
res.writeHead(200);
res.write('{');
});
expect((await new RemoteBuildRunner(partial, fetch, { tokenFile, timeoutMs: 100 }).run(commands)).ok).toBe(
false
);
const controller = new AbortController();
const pending = new RemoteBuildRunner(partial, fetch, { tokenFile }).run(commands, undefined, {
signal: controller.signal,
});
controller.abort();
expect(await pending).toMatchObject({ ok: false, aborted: true, output: 'Build cancelled by operator.' });
});
});
+6 -1
View File
@@ -29,7 +29,12 @@ const main = async (): Promise<void> => {
baseEnv: config.baseEnv,
});
const migrationRunner = new PnpmBuildRunner();
const releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner);
const releaseBuildRunner = createReleaseBuildRunner(
config.releaseBuilderUrl,
migrationRunner,
fetch,
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
);
const processManager = new Pm2ProcessManager();
const controller = new GatewayReleaseController(
repository,
@@ -160,7 +160,12 @@ export class GatewayReleaseController {
private readonly now: () => Date = () => new Date(),
private readonly fetchImpl: typeof fetch = fetch
) {
this.releaseBuildRunner = createReleaseBuildRunner(config.releaseBuilderUrl, migrationRunner, fetchImpl);
this.releaseBuildRunner = createReleaseBuildRunner(
config.releaseBuilderUrl,
migrationRunner,
fetchImpl,
config.baseEnv.RELEASE_BUILDER_TOKEN_FILE
);
this.artifactManager = new FrontendArtifactManager(config.frontendArtifactRoot ?? '/srv/frontend-artifacts');
}
@@ -509,7 +514,11 @@ export class GatewayReleaseController {
}
private async waitForReadiness(operationId: string): Promise<void> {
await this.appendLog(operationId, 'readiness', 'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.');
await this.appendLog(
operationId,
'readiness',
'Gateway API, 정적 frontend와 PM2 process readiness를 확인합니다.'
);
const deadline = performance.now() + this.config.readinessTimeoutMs;
const apiUrl = `http://127.0.0.1:${this.config.gatewayApiPort}/healthz`;
const frontendUrl =
@@ -524,10 +533,7 @@ export class GatewayReleaseController {
);
while (performance.now() < deadline) {
try {
const [api, frontend] = await Promise.all([
this.fetchImpl(apiUrl),
this.fetchImpl(frontendUrl),
]);
const [api, frontend] = await Promise.all([this.fetchImpl(apiUrl), this.fetchImpl(frontendUrl)]);
const processes = await this.processManager.list();
const expected = processes.filter((process) => expectedNames.includes(process.name));
const safe = expected.filter(
@@ -447,6 +447,8 @@ describe('GatewayReleaseController', () => {
it('runs release builds remotely while keeping Gateway migration on the runtime runner', async () => {
const workspace = await createReleaseWorkspace();
const tokenFile = path.join(workspace, 'builder-token.fixture');
await fs.writeFile(tokenFile, 't'.repeat(64), { mode: 0o600 });
const harness = createRepository();
const localCommandGroups: string[][] = [];
const remoteRequests: Array<{ commands: Array<{ args: string[] }> }> = [];
@@ -481,10 +483,15 @@ describe('GatewayReleaseController', () => {
running.delete(name);
},
},
{ ...config, releaseBuilderUrl: 'http://builder:15100' },
{
...config,
releaseBuilderUrl: 'http://builder:15100',
baseEnv: { ...config.baseEnv, RELEASE_BUILDER_TOKEN_FILE: tokenFile },
},
() => new Date('2026-08-01T00:00:00.000Z'),
async (input, init) => {
if (String(input) === 'http://builder:15100/v1/builds') {
expect(new Headers(init?.headers).get('x-release-builder-token')).toBe('t'.repeat(64));
remoteRequests.push(JSON.parse(String(init?.body)) as { commands: Array<{ args: string[] }> });
return new Response(`${JSON.stringify({ result: { ok: true, exitCode: 0, output: '' } })}\n`, {
status: 200,