From f78ab77850b1786257c0f68e69cdae4e324e1a46 Mon Sep 17 00:00:00 2001 From: hide_d Date: Fri, 9 Oct 2026 04:31:44 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=B4=EB=B6=80=20builder=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=EA=B3=BC=20=EC=8B=A4=ED=96=89=20=ED=97=88=EC=9A=A9=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EB=B0=8F=20=EC=9E=90=EC=9B=90=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=EB=A5=BC=20=EC=A0=81=EC=9A=A9=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 + README.md | 44 +++ compose.yaml | 4 + runtime/Dockerfile | 2 +- runtime/release-builder-client.mjs | 97 +++-- runtime/release-builder-security.mjs | 223 ++++++++++++ runtime/release-builder.mjs | 485 ++++++++++++++++--------- runtime/validate-compose-model.mjs | 10 +- runtime/validate-env.mjs | 1 + scripts/check.sh | 6 +- test/release-builder-security.test.mjs | 301 +++++++++++++++ test/validate-compose-model.test.mjs | 7 +- test/validate-env.test.mjs | 1 + 13 files changed, 979 insertions(+), 205 deletions(-) create mode 100644 runtime/release-builder-security.mjs create mode 100644 test/release-builder-security.test.mjs diff --git a/.env.example b/.env.example index 563c57c..352ad6b 100644 --- a/.env.example +++ b/.env.example @@ -106,3 +106,6 @@ IMAGE_SYNC_CORE2026_SECRET_FILE=./secrets/image_sync_core2026_secret # Initial stopped profile inventory. Each deployment can choose its own branch in the Admin GUI. BOOTSTRAP_PROFILES=gateway,che,kwe,pwe,twe,nya,pya,hwe TZ=Asia/Seoul + +# Dedicated random token file shared only by runtime and builder (mode 0600). +RELEASE_BUILDER_TOKEN_FILE=./secrets/release_builder_token diff --git a/README.md b/README.md index cf3c961..3ed3194 100644 --- a/README.md +++ b/README.md @@ -293,3 +293,47 @@ Caddy cache matcher의 적용·제외 경계는 다음처럼 비밀값 없이 ```sh node --test test/caddy-cache-policy.test.mjs ``` + +## Builder 인증 전환 + +`RELEASE_BUILDER_TOKEN_FILE`은 runtime과 builder에만 read-only로 mount하는 전용 +무작위 token 파일입니다. `.env`에는 파일 경로만 넣습니다. Git 제외 경로에 +mode 0600으로 생성하고 DB/Redis/OAuth secret을 재사용하지 않습니다. + +```sh +mkdir -p secrets +(umask 077; set -C; openssl rand -hex 32 > secrets/release_builder_token) +# .env: RELEASE_BUILDER_TOKEN_FILE=./secrets/release_builder_token +``` + +기존 파일이 있으면 위 생성 명령으로 교체하지 않습니다. `scripts/check.sh`는 +파일의 존재·권한과 양쪽 read-only mount를 검사합니다. Builder의 `/healthz`는 +인증 없이 조회할 수 있지만 `/v1/builds`는 `x-release-builder-token`이 필요합니다. +이 token은 HTTP header에만 쓰며 명령 payload와 progress/child env에는 넣지 않습니다. + +허용 명령은 Core package의 frozen install·build·Prisma generate와 고정 frontend +materialize script입니다. 실제 cwd/cache symlink, Node heap/thread 옵션도 검증합니다. +하나의 job을 실행하며 실행·대기·body 읽기 합계 3개, body 256 KiB/5초, +job 30분, NDJSON frame 512 KiB/stream 64 MiB와 pending response 1 MiB를 상한으로 +둡니다. 과부하 429와 잘못된 인증 401은 child를 만들지 않습니다. 연결 중단과 +deadline은 실행 process group을 종료하고 실제 종료까지 자리를 유지합니다. + +Builder는 신뢰하는 repository/dependency script를 실행합니다. 이 인증을 임의 +repository code에 대한 OS sandbox로 해석하지 않습니다. builder 전용 token 파일은 +같은 builder의 build code가 읽을 수 있지만 다른 운영 credential은 이 service와 +child env에 제공하지 않습니다. + +기존 stack에는 token mount·환경 경로부터 추가하고 **기존 builder를 유지한 상태**로 +새 Core의 controller와 Gateway를 먼저 갱신합니다. 이후 인증을 강제하는 새 builder와 +인증 헤더를 보내는 새 Docker bootstrap client를 함께 build·재생성합니다. 새 builder를 +먼저 적용하면 구형 client가 401로 실패합니다. Core의 `docs/release-operations.md`에 +같은 전환 순서를 기록했습니다. 현재 운영 기기에서 실행할 때는 SSH 재접속 없이 +해당 host의 wrapper를 직접 사용합니다. + +```sh +node --test test/*.test.mjs +``` + +실제 HTTP 테스트는 인증/UTF-8 body/대기열/취소/deadline/느린 소비자를 확인하며 +synthetic executable의 실제 자식 process와 종료도 검증합니다. 공개 운영의 적용 +근거는 해당 환경에서 별도로 확인해야 합니다. diff --git a/compose.yaml b/compose.yaml index 1b64faa..d57b7f9 100644 --- a/compose.yaml +++ b/compose.yaml @@ -48,10 +48,12 @@ services: TURBO_CACHE_DIR: /srv/builder-cache/turbo XDG_DATA_HOME: /srv/builder-cache/xdg RELEASE_BUILDER_PORT: '15100' + RELEASE_BUILDER_TOKEN_FILE: /run/secrets/release_builder_token TZ: ${TZ:-Asia/Seoul} volumes: - core-source:/srv/core - builder-cache:/srv/builder-cache + - ${RELEASE_BUILDER_TOKEN_FILE:?set RELEASE_BUILDER_TOKEN_FILE in .env}:/run/secrets/release_builder_token:ro healthcheck: test: [CMD, curl, --fail, --silent, http://127.0.0.1:15100/healthz] interval: 5s @@ -131,6 +133,7 @@ services: FRONTEND_SHARED_ASSET_PUBLIC_PATH: /gateway/profile-assets FRONTEND_READINESS_ORIGIN: http://caddy RELEASE_BUILDER_URL: http://builder:15100 + RELEASE_BUILDER_TOKEN_FILE: /run/secrets/release_builder_token RELEASE_BUILD_NODE_OPTIONS: ${BUILDER_NODE_OPTIONS:---max-old-space-size=3072} GATEWAY_TRPC_PATH: /gateway/api/trpc CORE_NAVIGATION_CONFIG_FILE: /srv/data/navigation.json @@ -154,6 +157,7 @@ services: volumes: - core-source:/srv/core - runtime-data:/srv/data + - ${RELEASE_BUILDER_TOKEN_FILE:?set RELEASE_BUILDER_TOKEN_FILE in .env}:/run/secrets/release_builder_token:ro - frontend-artifacts:/srv/frontend-artifacts - ${IMAGE_UPLOAD_CORE2026_SECRET_FILE:?set IMAGE_UPLOAD_CORE2026_SECRET_FILE in .env}:/run/secrets/image_upload_core2026_secret:ro - ${IMAGE_SYNC_CORE2026_SECRET_FILE:?set IMAGE_SYNC_CORE2026_SECRET_FILE in .env}:/run/secrets/image_sync_core2026_secret:ro diff --git a/runtime/Dockerfile b/runtime/Dockerfile index 0857882..a2f67be 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -10,7 +10,7 @@ COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh COPY --chmod=755 runtime/git-askpass.sh /opt/sammo/git-askpass.sh COPY --chmod=755 runtime/checkout-active-release.sh /opt/sammo/checkout-active-release.sh -COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs runtime/validate-env.mjs runtime/release-builder.mjs runtime/release-builder-client.mjs /opt/sammo/ +COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs runtime/validate-env.mjs runtime/release-builder.mjs runtime/release-builder-security.mjs runtime/release-builder-client.mjs /opt/sammo/ WORKDIR /srv/core/repository ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"] diff --git a/runtime/release-builder-client.mjs b/runtime/release-builder-client.mjs index 3bc355d..b2e23bb 100644 --- a/runtime/release-builder-client.mjs +++ b/runtime/release-builder-client.mjs @@ -1,57 +1,78 @@ -const builderUrl = process.env.RELEASE_BUILDER_URL ?? 'http://builder:15100'; -const coreRoot = process.env.GATEWAY_WORKSPACE_ROOT ?? '/srv/core/repository'; +import { + BUILD_TIMEOUT_MS, + readBuilderToken, + readBuilderMessages, +} from "./release-builder-security.mjs"; +const builderUrl = process.env.RELEASE_BUILDER_URL ?? "http://builder:15100"; +const coreRoot = process.env.GATEWAY_WORKSPACE_ROOT ?? "/srv/core/repository"; const publicBuildEnv = Object.fromEntries( Object.entries(process.env).filter( ([name, value]) => - typeof value === 'string' && - /^(?:CI|NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/.test(name), + typeof value === "string" && + /^(?:CI|NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/.test( + name, + ), ), ); if (process.env.RELEASE_BUILD_NODE_OPTIONS) { publicBuildEnv.NODE_OPTIONS = process.env.RELEASE_BUILD_NODE_OPTIONS; } delete publicBuildEnv.RELEASE_BUILD_NODE_OPTIONS; -const pnpm = (...args) => ({ command: 'pnpm', args, cwd: coreRoot, env: publicBuildEnv }); +const pnpm = (...args) => ({ + command: "pnpm", + args, + cwd: coreRoot, + env: publicBuildEnv, +}); const commands = [ - pnpm('install', '--frozen-lockfile'), - pnpm('--filter', '@sammo-ts/common', 'build'), - pnpm('--filter', '@sammo-ts/infra', 'prisma:generate'), - pnpm('--filter', '@sammo-ts/infra', 'build'), - pnpm('--filter', '@sammo-ts/logic', 'build'), - pnpm('--filter', '@sammo-ts/game-engine', 'build'), - pnpm('--filter', '@sammo-ts/gateway-api', 'build'), - pnpm('--filter', '@sammo-ts/gateway-frontend', 'build'), - pnpm('--filter', '@sammo-ts/release-controller', 'build'), + pnpm("install", "--frozen-lockfile"), + pnpm("--filter", "@sammo-ts/common", "build"), + pnpm("--filter", "@sammo-ts/infra", "prisma:generate"), + pnpm("--filter", "@sammo-ts/infra", "build"), + pnpm("--filter", "@sammo-ts/logic", "build"), + pnpm("--filter", "@sammo-ts/game-engine", "build"), + pnpm("--filter", "@sammo-ts/gateway-api", "build"), + pnpm("--filter", "@sammo-ts/gateway-frontend", "build"), + pnpm("--filter", "@sammo-ts/release-controller", "build"), ]; -const response = await fetch(new URL('/v1/builds', builderUrl), { - method: 'POST', - headers: { 'content-type': 'application/json' }, +const response = await fetch(new URL("/v1/builds", builderUrl), { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(BUILD_TIMEOUT_MS), + headers: { + "content-type": "application/json", + "x-release-builder-token": readBuilderToken( + process.env.RELEASE_BUILDER_TOKEN_FILE, + ), + }, body: JSON.stringify({ commands }), }); -if (!response.ok || !response.body) { - throw new Error((await response.text()) || `Release builder returned HTTP ${response.status}.`); -} -const decoder = new TextDecoder(); -let buffer = ''; let result = null; -const handleLine = (line) => { - if (!line.trim()) return; - const message = JSON.parse(line); - if (message.event?.type === 'OUTPUT') { +for await (const message of readBuilderMessages(response)) { + if (message.event?.type === "OUTPUT") { + if ( + !["stdout", "stderr"].includes(message.event.stream) || + typeof message.event.message !== "string" || + message.event.message.length > 2000 + ) + throw new Error("Invalid release build progress."); const output = `${message.event.message}\n`; - if (message.event.stream === 'stderr') process.stderr.write(output); + if (message.event.stream === "stderr") process.stderr.write(output); else process.stdout.write(output); } - if (message.result) result = message.result; -}; -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) handleLine(line); -} -handleLine(buffer); -if (!result?.ok) { - throw new Error(result?.output || 'Release builder closed without a successful result.'); + if (message.result) { + if ( + typeof message.result.ok !== "boolean" || + (message.result.ok && + (message.result.exitCode !== 0 || message.result.aborted === true)) || + typeof message.result.output !== "string" || + message.result.output.length > 64 * 1024 + ) + throw new Error("Invalid release build result."); + result = message.result; + break; + } } +if (!result?.ok) + throw new Error("Release builder did not produce a successful result."); diff --git a/runtime/release-builder-security.mjs b/runtime/release-builder-security.mjs new file mode 100644 index 0000000..a7ee981 --- /dev/null +++ b/runtime/release-builder-security.mjs @@ -0,0 +1,223 @@ +import { readFileSync, realpathSync, statSync } from "node:fs"; +import path from "node:path"; + +export const MAX_BUILD_FRAME_BYTES = 512 * 1024; +export const BUILD_TIMEOUT_MS = 30 * 60 * 1000; +const packages = new Set( + [ + "common", + "infra", + "logic", + "game-engine", + "game-api", + "game-frontend", + "gateway-api", + "gateway-frontend", + "release-controller", + ].map((name) => `@sammo-ts/${name}`), +); +const within = (candidate, root) => { + const relative = path.relative(root, candidate); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +}; + +export const readBuilderToken = (filename) => { + 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."); + } +}; + +const statExists = (filename) => { + try { + statSync(filename); + return true; + } catch { + return false; + } +}; +const safeCachePath = (value, roots) => { + if (!path.isAbsolute(value) || value.length > 2048) + throw new Error("Invalid build cache path."); + const resolved = path.resolve(value); + if (!roots.some((root) => within(resolved, root))) + throw new Error("Build cache is outside shared storage."); + let existing = resolved; + while (!statExists(existing)) existing = path.dirname(existing); + if (!roots.some((root) => within(realpathSync(existing), root))) + throw new Error("Build cache symlink leaves shared storage."); + return resolved; +}; +const safeEnv = (env, roots) => { + if ( + !env || + typeof env !== "object" || + Array.isArray(env) || + Object.keys(env).length > 32 + ) + throw new Error("Invalid build environment."); + const result = {}; + for (const [name, value] of Object.entries(env)) { + if ( + typeof value !== "string" || + value.length > 8192 || + value.includes("\0") + ) + throw new Error("Invalid build environment."); + if ( + /^VITE_[A-Z0-9_]+$/.test(name) || + name === "TZ" || + (name === "CI" && ["true", "false", "1", "0"].includes(value)) + ) + result[name] = value; + else if ( + ["NODE_OPTIONS", "PROFILE_FRONTEND_BUILD_NODE_OPTIONS"].includes(name) + ) { + const heap = /^--max-old-space-size=(\d+)$/.exec(value); + if (!heap || Number(heap[1]) < 512 || Number(heap[1]) > 3584) + throw new Error("Invalid build heap control."); + result[name] = value; + } else if ( + ["RAYON_NUM_THREADS", "RELEASE_TURBO_CONCURRENCY"].includes(name) + ) { + if (!/^[1-4]$/.test(value)) + throw new Error("Invalid build concurrency control."); + result[name] = value; + } else if (name === "TURBO_CACHE_DIR") + result[name] = safeCachePath(value, roots); + else if (name !== "PATH") + throw new Error("Unsupported build environment variable."); + } + return result; +}; + +export const validateBuildCommand = (raw, allowedRoots, cacheRoots) => { + if ( + !raw || + typeof raw !== "object" || + Array.isArray(raw) || + !Array.isArray(raw.args) || + raw.args.length > 40 || + typeof raw.cwd !== "string" || + raw.cwd.length > 2048 || + !path.isAbsolute(raw.cwd) || + raw.args.some( + (arg) => + typeof arg !== "string" || arg.length > 2048 || arg.includes("\0"), + ) + ) + throw new Error("Invalid build command."); + const cwd = realpathSync(raw.cwd); + if (!allowedRoots.some((root) => within(cwd, root))) + throw new Error("Build cwd is outside shared storage."); + const args = raw.args; + const install = + args.length === 2 && + args[0] === "install" && + args[1] === "--frozen-lockfile"; + const packageTask = + args.length === 3 && + args[0] === "--filter" && + packages.has(args[1]) && + (args[2] === "build" || + (args[1] === "@sammo-ts/infra" && args[2] === "prisma:generate")); + let turbo = false; + if ( + args[0] === "exec" && + args[1] === "turbo" && + args[2] === "run" && + ["build", "build:release"].includes(args[3]) + ) { + const flags = args.slice(4); + const seen = new Set(); + let filters = 0; + turbo = + flags.every((flag) => { + if (flag.startsWith("--filter=")) { + filters += 1; + return packages.has(flag.slice(9)); + } + const name = flag.split("=")[0]; + if (seen.has(name)) return false; + seen.add(name); + if (flag.startsWith("--cache-dir=")) { + safeCachePath(flag.slice(12), cacheRoots); + return true; + } + return ( + /^--concurrency=[1-4]$/.test(flag) || + flag === "--ui=stream" || + flag === "--output-logs=new-only" + ); + }) && filters > 0; + } + const materialize = + args.length === 2 && + args[0] === "tools/build-scripts/materialize-profile-frontend.mjs" && + /^(?:che|kwe|pwe|twe|nya|pya|hwe):[A-Za-z0-9_.-]{1,64}$/.test(args[1]); + if (!( + (raw.command === "pnpm" && (install || packageTask || turbo)) || + (raw.command === "node" && materialize) + )) + throw new Error("Unsupported release build command."); + return { + command: raw.command, + args, + cwd, + env: safeEnv(raw.env ?? {}, cacheRoots), + }; +}; + +export async function* readBuilderMessages(response) { + if (!response.ok || !response.body) { + await response.body?.cancel().catch(() => undefined); + throw new Error(`Release builder returned HTTP ${response.status}.`); + } + const reader = response.body.getReader(); + const decoder = new TextDecoder("utf-8", { fatal: true }); + let pending = ""; + let bytes = 0; + try { + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + bytes += chunk.value.byteLength; + if (bytes > 64 * 1024 * 1024) + throw new Error("Release builder stream is too large."); + pending += decoder.decode(chunk.value, { stream: true }); + let newline; + while ((newline = pending.indexOf("\n")) >= 0) { + const line = pending.slice(0, newline); + pending = pending.slice(newline + 1); + if (Buffer.byteLength(line) > MAX_BUILD_FRAME_BYTES) + throw new Error("Release builder frame is too large."); + if (line.trim()) yield JSON.parse(line); + } + if (Buffer.byteLength(pending) > MAX_BUILD_FRAME_BYTES) + throw new Error("Release builder frame is too large."); + } + pending += decoder.decode(); + if (Buffer.byteLength(pending) > MAX_BUILD_FRAME_BYTES) + throw new Error("Release builder frame is too large."); + if (pending.trim()) yield JSON.parse(pending); + } finally { + await reader.cancel().catch(() => undefined); + reader.releaseLock(); + } +} diff --git a/runtime/release-builder.mjs b/runtime/release-builder.mjs index e6a3613..e14a85a 100644 --- a/runtime/release-builder.mjs +++ b/runtime/release-builder.mjs @@ -1,60 +1,25 @@ -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 }, - }; -}; +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 { @@ -63,120 +28,314 @@ const terminate = (child, signal) => { try { child.kill(signal); } catch { - // The build already exited. + /* 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(); +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); }; - 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 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); }); -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', +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 } : {}), + }); + }); }); - 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 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}).`); }; - 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)); - } -}); + 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; +}; -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); +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); +} diff --git a/runtime/validate-compose-model.mjs b/runtime/validate-compose-model.mjs index 16e78a9..dbccd92 100644 --- a/runtime/validate-compose-model.mjs +++ b/runtime/validate-compose-model.mjs @@ -14,6 +14,14 @@ export const validateComposeModel = (model, mode) => { const caddy = model?.services?.caddy; if (!builder) errors.push('builder service is missing'); if (!caddy) errors.push('caddy service is missing'); + for (const [name, service] of [['runtime', runtime], ['builder', builder]]) { + const target = '/run/secrets/release_builder_token'; + if (service?.environment?.RELEASE_BUILDER_TOKEN_FILE !== target) errors.push(`${name} must use the mounted builder token file`); + const mount = (service?.volumes ?? []).find((entry) => entry.target === target); + if (!mount || mount.type !== 'bind' || mount.read_only !== true) errors.push(`${name} builder token mount must be read-only`); + } + if ((builder?.ports ?? []).length) errors.push('builder must not publish host ports'); + const requiredImageEnvironment = { GATEWAY_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret', @@ -120,7 +128,7 @@ export const validateComposeModel = (model, mode) => { errors.push('builder Rayon thread count must be between 1 and 4'); } for (const name of Object.keys(builder.environment ?? {})) { - if (/(?:SECRET|TOKEN|PASSWORD|DATABASE_URL|REDIS_URL)/i.test(name)) { + if (name !== 'RELEASE_BUILDER_TOKEN_FILE' && /(?:SECRET|TOKEN|PASSWORD|DATABASE_URL|REDIS_URL)/i.test(name)) { errors.push(`builder must not receive sensitive environment variable ${name}`); } } diff --git a/runtime/validate-env.mjs b/runtime/validate-env.mjs index 3916b9f..0ab4415 100644 --- a/runtime/validate-env.mjs +++ b/runtime/validate-env.mjs @@ -12,6 +12,7 @@ const REQUIRED = [ 'INITIAL_ADMIN_USERNAME', 'INITIAL_ADMIN_PASSWORD', 'KAKAO_REST_KEY', + 'RELEASE_BUILDER_TOKEN_FILE', ]; const MIN_LENGTH = new Map([ diff --git a/scripts/check.sh b/scripts/check.sh index db2e7e5..bb95183 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -10,7 +10,7 @@ if [ ! -f "$env_file" ]; then exit 66 fi -secret_file_keys='IMAGE_UPLOAD_CORE2026_SECRET_FILE IMAGE_SYNC_CORE2026_SECRET_FILE' +secret_file_keys='IMAGE_UPLOAD_CORE2026_SECRET_FILE IMAGE_SYNC_CORE2026_SECRET_FILE RELEASE_BUILDER_TOKEN_FILE' if [ "$(sed -n 's/^WEB_PUSH_ENABLED=//p' "$env_file" | tail -n 1)" = true ]; then secret_file_keys="$secret_file_keys WEB_PUSH_VAPID_PRIVATE_KEY_FILE" fi @@ -33,6 +33,10 @@ for key in $secret_file_keys; do echo "$key must not use the tracked example placeholder." >&2 exit 66 fi + if [ "$key" = RELEASE_BUILDER_TOKEN_FILE ] && [ "$(stat -c %a "$secret_path")" != 600 ]; then + echo 'RELEASE_BUILDER_TOKEN_FILE must have mode 0600.' >&2 + exit 66 + fi done docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \ diff --git a/test/release-builder-security.test.mjs b/test/release-builder-security.test.mjs new file mode 100644 index 0000000..1669e81 --- /dev/null +++ b/test/release-builder-security.test.mjs @@ -0,0 +1,301 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { request } from "node:http"; +import { createReleaseBuilderServer } from "../runtime/release-builder.mjs"; +import { + validateBuildCommand, + readBuilderToken, +} from "../runtime/release-builder-security.mjs"; + +const token = "t".repeat(64); +const fixture = async (options = {}) => { + const root = await fs.mkdtemp( + path.join(os.tmpdir(), "builder-http-security-"), + ); + const bin = path.join(root, "bin"); + await fs.mkdir(bin); + await fs.writeFile( + path.join(bin, "pnpm"), + `#!${process.execPath}\nconst mode=process.env.VITE_FIXTURE_MODE;\nif(mode==='hold'||mode==='ignore'){if(mode==='ignore')process.on('SIGTERM',()=>{});console.log('ready');setInterval(()=>{},1000)}else if(mode==='flood'){process.stdout.write('x'.repeat(20*1024*1024));}else{console.log(JSON.stringify({args:process.argv.slice(2),secretInChild:Boolean(process.env.RELEASE_BUILDER_TOKEN_FILE||process.env.DATABASE_URL||process.env.UNRELATED_SECRET),publicValue:process.env.VITE_FIXTURE_VALUE}));}\n`, + { mode: 0o755 }, + ); + const children = []; + const server = createReleaseBuilderServer({ + token, + allowedRoots: [root], + cacheRoots: [root], + baseEnv: { + ...process.env, + PATH: `${bin}:${process.env.PATH}`, + RELEASE_BUILDER_TOKEN_FILE: "/private/token", + DATABASE_URL: "synthetic-private", + UNRELATED_SECRET: "synthetic-private", + }, + spawnImpl: (...args) => { + const child = spawn(...args); + children.push(child); + return child; + }, + ...options, + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const url = `http://127.0.0.1:${server.address().port}/v1/builds`; + const command = (env = {}) => ({ + command: "pnpm", + args: ["install", "--frozen-lockfile"], + cwd: root, + env, + }); + const post = (commands = [command()], presented = token, signal) => + fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "x-release-builder-token": presented, + }, + body: JSON.stringify({ commands }), + signal, + }); + const close = async () => { + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + await fs.rm(root, { recursive: true, force: true }); + }; + return { root, url, command, post, children, close }; +}; +const alive = (pid) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; +const waitFor = async (predicate) => { + const until = Date.now() + 5000; + while (!predicate()) { + if (Date.now() >= until) + throw new Error("Fixture state deadline exceeded."); + await new Promise((resolve) => setTimeout(resolve, 20)); + } +}; + +test("rejects missing, placeholder and oversized token files without exposing values", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "builder-secret-test-")); + try { + assert.throws( + () => readBuilderToken(path.join(root, "missing")), + /A valid RELEASE_BUILDER_TOKEN_FILE/, + ); + const filename = path.join(root, "token"); + for (const value of ["replace-with-token", "x".repeat(257)]) { + await fs.writeFile(filename, value); + assert.throws( + () => readBuilderToken(filename), + /A valid RELEASE_BUILDER_TOKEN_FILE/, + ); + } + await fs.writeFile(filename, token); + assert.equal(readBuilderToken(filename), token); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +}); + +test("rejects unauthenticated HTTP before parsing a command and executes authenticated work without inherited secrets", async () => { + const f = await fixture(); + try { + for (const presented of ["", "x".repeat(64)]) { + const response = await f.post([f.command()], presented); + assert.equal(response.status, 401); + assert.equal(f.children.length, 0); + } + const response = await f.post([ + f.command({ VITE_FIXTURE_VALUE: "public", PATH: "/attacker/bin" }), + ]); + assert.equal(response.status, 200); + const body = await response.text(); + assert.match(body, /secretInChild\\\":false/); + assert.match(body, /publicValue\\\":\\\"public/); + assert.ok(!body.includes(token)); + assert.ok(!body.includes("/attacker/bin")); + assert.equal(JSON.parse(body.trim().split("\n").at(-1)).result.ok, true); + } finally { + await f.close(); + } +}); + +test("denies extra pnpm flags, arbitrary packages/tasks, unsafe Node controls and escaping symlinks", async () => { + const f = await fixture(); + const outside = await fs.mkdtemp(path.join(os.tmpdir(), "builder-outside-")); + try { + await fs.symlink(outside, path.join(f.root, "escape")); + const invalid = [ + { ...f.command(), args: ["install", "--frozen-lockfile", "--dir=/tmp"] }, + { + ...f.command(), + args: ["exec", "turbo", "run", "arbitrary", "--filter=@sammo-ts/infra"], + }, + { ...f.command(), args: ["--filter", "attacker-package", "build"] }, + { ...f.command(), env: { NODE_OPTIONS: "--require=/tmp/attack.cjs" } }, + { + ...f.command(), + env: { TURBO_CACHE_DIR: path.join(f.root, "escape", "cache") }, + }, + { ...f.command(), cwd: path.join(f.root, "escape") }, + { ...f.command(), command: "node", args: ["-e", "process.exit(0)"] }, + ]; + for (const command of invalid) { + assert.equal((await f.post([command])).status, 400); + assert.equal(f.children.length, 0); + } + const valid = { + ...f.command(), + args: [ + "exec", + "turbo", + "run", + "build:release", + "--filter=@sammo-ts/game-frontend", + `--cache-dir=${f.root}/cache`, + "--concurrency=1", + "--ui=stream", + "--output-logs=new-only", + ], + env: { + NODE_OPTIONS: "--max-old-space-size=3072", + RAYON_NUM_THREADS: "2", + }, + }; + assert.deepEqual( + validateBuildCommand(valid, [f.root], [f.root]).args, + valid.args, + ); + } finally { + await f.close(); + await fs.rm(outside, { recursive: true, force: true }); + } +}); + +test("caps actual UTF-8 request bytes before child execution", async () => { + const f = await fixture(); + try { + const response = await fetch(f.url, { + method: "POST", + headers: { "x-release-builder-token": token }, + body: JSON.stringify({ + commands: [f.command()], + extra: "한".repeat(100_000), + }), + }); + assert.equal(response.status, 413); + assert.equal(f.children.length, 0); + } finally { + await f.close(); + } +}); + +test("caps active plus queued requests and reclaims capacity after client disconnects", async () => { + const f = await fixture(); + const controllers = Array.from({ length: 3 }, () => new AbortController()); + try { + const responses = []; + for (const controller of controllers) + responses.push( + await f.post( + [f.command({ VITE_FIXTURE_MODE: "hold" })], + token, + controller.signal, + ), + ); + const denied = await f.post(); + assert.equal(denied.status, 429); + assert.equal(denied.headers.get("retry-after"), "1"); + await waitFor(() => f.children.length > 0); + for (const controller of controllers) controller.abort(); + await Promise.all( + responses.map((response) => response.text().catch(() => "")), + ); + await waitFor(() => f.children.every((child) => !alive(child.pid))); + let recovered; + for (let i = 0; i < 100; i++) { + recovered = await f.post(); + if (recovered.status !== 429) break; + await recovered.text(); + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.equal(recovered.status, 200); + assert.equal( + JSON.parse((await recovered.text()).trim().split("\n").at(-1)).result.ok, + true, + ); + } finally { + for (const controller of controllers) controller.abort(); + await f.close(); + } +}); + +test("job deadline kills a subprocess that ignores SIGTERM and returns a failed result", async () => { + const f = await fixture({ jobTimeoutMs: 1000, terminateGraceMs: 50 }); + try { + const response = await f.post([f.command({ VITE_FIXTURE_MODE: "ignore" })]); + const body = await response.text(); + assert.match(body, /ready/); + const result = JSON.parse(body.trim().split("\n").at(-1)).result; + assert.equal(result.ok, false); + assert.equal(result.aborted, true); + assert.equal(f.children.length, 1); + assert.equal(alive(f.children[0].pid), false); + } finally { + await f.close(); + } +}); + +test("slow response consumer cannot retain a flooding build subprocess", async () => { + const f = await fixture({ terminateGraceMs: 50 }); + try { + const response = await f.post([f.command({ VITE_FIXTURE_MODE: "flood" })]); + await waitFor(() => f.children.length === 1 && !alive(f.children[0].pid)); + await response.body.cancel().catch(() => {}); + const next = await f.post(); + assert.equal(next.status, 200); + assert.equal( + JSON.parse((await next.text()).trim().split("\n").at(-1)).result.ok, + true, + ); + } finally { + await f.close(); + } +}); + +test("a stalled authenticated body is rejected within the body deadline", async () => { + const f = await fixture({ bodyTimeoutMs: 100 }); + try { + const status = await new Promise((resolve, reject) => { + const req = request( + f.url, + { + method: "POST", + headers: { + "x-release-builder-token": token, + "content-length": "100", + }, + }, + (res) => { + res.resume(); + res.once("end", () => resolve(res.statusCode)); + }, + ); + req.once("error", reject); + req.flushHeaders(); + }); + assert.equal(status, 408); + assert.equal(f.children.length, 0); + } finally { + await f.close(); + } +}); diff --git a/test/validate-compose-model.test.mjs b/test/validate-compose-model.test.mjs index 926b432..28887fe 100644 --- a/test/validate-compose-model.test.mjs +++ b/test/validate-compose-model.test.mjs @@ -6,6 +6,7 @@ import { validateComposeModel } from '../runtime/validate-compose-model.mjs'; const safeRuntime = { environment: { CORE_SOURCE_MODE: 'clone', + RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token', NODE_OPTIONS: '--max-old-space-size=1536', TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=3072', RAYON_NUM_THREADS: '2', @@ -28,6 +29,7 @@ const safeRuntime = { GATEWAY_ACTIVE_RELEASE_GIT_REF: 'refs/sammo/active-gateway', }, volumes: [ + { type: 'bind', source: '/secrets/release_builder_token', target: '/run/secrets/release_builder_token', read_only: true }, { type: 'bind', source: '/secrets/image_upload_core2026_secret', @@ -61,7 +63,9 @@ const safeRuntime = { }; const safeBuilder = { + volumes: [{ type: 'bind', source: '/secrets/release_builder_token', target: '/run/secrets/release_builder_token', read_only: true }], environment: { + RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token', NODE_OPTIONS: '--max-old-space-size=3072', RAYON_NUM_THREADS: '2', }, @@ -145,7 +149,8 @@ test('rejects missing or excessive build memory and parallelism limits', () => { ...safeRuntime, environment: { CORE_SOURCE_MODE: 'clone', - NODE_OPTIONS: '--max-old-space-size=4096', + RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token', + NODE_OPTIONS: '--max-old-space-size=4096', TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=8192', RAYON_NUM_THREADS: '8', }, diff --git a/test/validate-env.test.mjs b/test/validate-env.test.mjs index 305204e..77e569d 100644 --- a/test/validate-env.test.mjs +++ b/test/validate-env.test.mjs @@ -14,6 +14,7 @@ const validEnv = { INITIAL_ADMIN_USERNAME: 'admin', INITIAL_ADMIN_PASSWORD: 'a'.repeat(16), KAKAO_REST_KEY: 'kakao-test-key', + RELEASE_BUILDER_TOKEN_FILE: '/secrets/release_builder_token', }; test('accepts a complete public repository environment', () => {