From 6755f09ee7c60c397b2a6806a9704438528ec25c Mon Sep 17 00:00:00 2001 From: hide_d Date: Fri, 9 Oct 2026 04:31:44 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9B=90=EA=B2=A9=20=EB=A6=B4=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=20=EB=B9=8C=EB=93=9C=EC=97=90=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=EA=B3=BC=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EC=A0=9C=ED=95=9C=EC=9D=84=20=EC=A0=81=EC=9A=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 + .../src/orchestrator/buildRunner.ts | 191 ++++++++++++++---- .../src/orchestrator/gatewayOrchestrator.ts | 3 +- .../test/remoteBuildRunner.test.ts | 19 +- .../test/remoteBuildRunnerHttp.test.ts | 114 +++++++++++ app/release-controller/src/index.ts | 7 +- .../src/releaseController.ts | 18 +- .../test/releaseController.test.ts | 9 +- docs/release-operations.md | 37 ++++ 9 files changed, 351 insertions(+), 51 deletions(-) create mode 100644 app/gateway-api/test/remoteBuildRunnerHttp.test.ts diff --git a/.env.example b/.env.example index 782f4cee..9806b56e 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/app/gateway-api/src/orchestrator/buildRunner.ts b/app/gateway-api/src/orchestrator/buildRunner.ts index 1c63781d..5b276dc4 100644 --- a/app/gateway-api/src/orchestrator/buildRunner.ts +++ b/app/gateway-api/src/orchestrator/buildRunner.ts @@ -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 | 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 => + 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 { 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 | 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 { - let message: RemoteBuildMessage; + private async handleRemoteMessage(line: string, onProgress?: BuildProgressObserver): Promise { + 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); diff --git a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts index 96ae60e6..1a629135 100644 --- a/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts +++ b/app/gateway-api/src/orchestrator/gatewayOrchestrator.ts @@ -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; diff --git a/app/gateway-api/test/remoteBuildRunner.test.ts b/app/gateway-api/test/remoteBuildRunner.test.ts index 131615a2..e897677a 100644 --- a/app/gateway-api/test/remoteBuildRunner.test.ts +++ b/app/gateway-api/test/remoteBuildRunner.test.ts @@ -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 }>; }; @@ -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', diff --git a/app/gateway-api/test/remoteBuildRunnerHttp.test.ts b/app/gateway-api/test/remoteBuildRunnerHttp.test.ts new file mode 100644 index 00000000..2313f56c --- /dev/null +++ b/app/gateway-api/test/remoteBuildRunnerHttp.test.ts @@ -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((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((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.' }); + }); +}); diff --git a/app/release-controller/src/index.ts b/app/release-controller/src/index.ts index 7750dd9a..7139adb2 100644 --- a/app/release-controller/src/index.ts +++ b/app/release-controller/src/index.ts @@ -29,7 +29,12 @@ const main = async (): Promise => { 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, diff --git a/app/release-controller/src/releaseController.ts b/app/release-controller/src/releaseController.ts index f7f0f0d8..0fcf5da4 100644 --- a/app/release-controller/src/releaseController.ts +++ b/app/release-controller/src/releaseController.ts @@ -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 { - 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( diff --git a/app/release-controller/test/releaseController.test.ts b/app/release-controller/test/releaseController.test.ts index 9dbb9804..f1cb1982 100644 --- a/app/release-controller/test/releaseController.test.ts +++ b/app/release-controller/test/releaseController.test.ts @@ -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, diff --git a/docs/release-operations.md b/docs/release-operations.md index ec6760d4..8244609d 100644 --- a/docs/release-operations.md +++ b/docs/release-operations.md @@ -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를 따릅니다.