원격 릴리스 빌드에 토큰 파일 인증과 응답 제한을 적용한다
This commit is contained in:
@@ -107,3 +107,7 @@ VITE_CASUAL_CHAT_URL=
|
||||
# 수동 turn-daemon 기동 시 실행 산출물의 확정된 전체 commit SHA. 미확인이면 비워 둔다.
|
||||
# Gateway 관리 daemon은 프로필 buildCommitSha를 자동 전달하므로 수동 설정하지 않는다.
|
||||
TURN_BUILD_COMMIT_SHA=
|
||||
|
||||
# Set both only when builds use the dedicated remote builder; never expose token to VITE_*.
|
||||
RELEASE_BUILDER_URL=
|
||||
RELEASE_BUILDER_TOKEN_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;
|
||||
|
||||
@@ -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.' });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -617,3 +617,40 @@ Kakao transport는 각 HTTP 요청의 header와 body 전체에 10초 deadline, d
|
||||
logout과 game-session 교환에 동일한 인증 시작 budget을 강제로 적용하지 않는다.
|
||||
임시 운영 stack과 공개 운영/개발 stack은 Redis와 prefix를 공유하지 않으며 새 코드의
|
||||
검증 결과를 실제 공개 배포 근거로 사용하지 않는다.
|
||||
|
||||
### Builder 요청 인증과 자원 경계 (2026-10-09)
|
||||
|
||||
`RELEASE_BUILDER_URL`을 설정한 원격 build는 전용 `RELEASE_BUILDER_TOKEN_FILE`이
|
||||
필요합니다. 파일의 무작위 token은 `x-release-builder-token` 헤더로만 전달하며
|
||||
명령·progress event·`VITE_*`에 넣지 않습니다. 파일 누락/오류는 build 실패이며
|
||||
로컬 runner로 우회하지 않습니다. HTTP redirect를 따라가지 않고 오류 response의
|
||||
원문 body도 릴리스 로그에 복사하지 않습니다.
|
||||
|
||||
Docker builder와 runtime에 같은 파일을 read-only mount합니다. host 파일은 Git
|
||||
제외·mode 0600으로 보존하고 DB/Redis/OAuth/bootstrap token과 공유하지 않습니다.
|
||||
이 경계는 신뢰하는 Core repository와 dependency의 build script를 실행하기 위한
|
||||
인증입니다. 악의적인 repository code를 OS 수준으로 격리하는 sandbox가 아니며
|
||||
builder 파일 시스템에 있는 builder 전용 token은 신뢰하는 build code가 읽을 수
|
||||
있습니다. 다른 운영 credential은 builder에 mount하거나 child env로 전달하지 않습니다.
|
||||
|
||||
Builder는 허용된 package의 frozen install·build·Prisma generate·frontend materialize만
|
||||
실행하며 임의 task/flag, cwd/cache의 탈출 symlink와 Node preload 옵션을 거부합니다.
|
||||
동시에 실행하는 job은 하나이며 실행·대기·body 읽기를 합쳐 3개까지만 받습니다.
|
||||
초과 요청은 429이고, byte 기준 body 256 KiB/읽기 5초, job 전체 30분,
|
||||
NDJSON frame 512 KiB/stream 64 MiB, 느린 소비자의 write buffer 1 MiB를 상한으로 둡니다.
|
||||
취소·deadline·response 중단은 자식 process group을 TERM/KILL하며 실제 종료까지
|
||||
대기 자리를 보존합니다. Core client도 30분 deadline과 같은 frame/stream 상한을
|
||||
적용하고 progress/result의 실제 type과 길이를 검사합니다.
|
||||
|
||||
기존 무인증 builder에서 전환할 때는 다음 순서로 작업합니다.
|
||||
|
||||
1. 전용 token 파일을 준비하고 현재 이미지에 mount·환경 경로만 추가합니다.
|
||||
2. 기존 builder를 유지한 채 새 Core controller self-upgrade와 Gateway UPDATE를
|
||||
수행해 새 인증 client를 활성화합니다. 기존 builder는 추가 헤더를 허용합니다.
|
||||
3. 인증을 강제하는 새 Docker builder와 bootstrap client를 함께 build·재생성합니다.
|
||||
4. 인증 없는 POST가 401이고 정상 bootstrap/release가 성공하는지 확인합니다.
|
||||
|
||||
새 builder를 먼저 적용하면 구형 Core/구형 Docker bootstrap client의 요청이 401로
|
||||
실패합니다. 해당 host의 wrapper와 정확한 project를 사용하고 기존 volume·active
|
||||
release를 보존합니다. 외부 공개 stack과 임시 localhost stack의 token 파일은
|
||||
공유하지 않습니다. 임시 host의 절차는 상위 local-runtime README를 따릅니다.
|
||||
|
||||
Reference in New Issue
Block a user