13 changed files with 979 additions and 205 deletions
+3
View File
@@ -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. # 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 BOOTSTRAP_PROFILES=gateway,che,kwe,pwe,twe,nya,pya,hwe
TZ=Asia/Seoul TZ=Asia/Seoul
# Dedicated random token file shared only by runtime and builder (mode 0600).
RELEASE_BUILDER_TOKEN_FILE=./secrets/release_builder_token
+44
View File
@@ -293,3 +293,47 @@ Caddy cache matcher의 적용·제외 경계는 다음처럼 비밀값 없이
```sh ```sh
node --test test/caddy-cache-policy.test.mjs 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와 종료도 검증합니다. 공개 운영의 적용
근거는 해당 환경에서 별도로 확인해야 합니다.
+4
View File
@@ -48,10 +48,12 @@ services:
TURBO_CACHE_DIR: /srv/builder-cache/turbo TURBO_CACHE_DIR: /srv/builder-cache/turbo
XDG_DATA_HOME: /srv/builder-cache/xdg XDG_DATA_HOME: /srv/builder-cache/xdg
RELEASE_BUILDER_PORT: '15100' RELEASE_BUILDER_PORT: '15100'
RELEASE_BUILDER_TOKEN_FILE: /run/secrets/release_builder_token
TZ: ${TZ:-Asia/Seoul} TZ: ${TZ:-Asia/Seoul}
volumes: volumes:
- core-source:/srv/core - core-source:/srv/core
- builder-cache:/srv/builder-cache - builder-cache:/srv/builder-cache
- ${RELEASE_BUILDER_TOKEN_FILE:?set RELEASE_BUILDER_TOKEN_FILE in .env}:/run/secrets/release_builder_token:ro
healthcheck: healthcheck:
test: [CMD, curl, --fail, --silent, http://127.0.0.1:15100/healthz] test: [CMD, curl, --fail, --silent, http://127.0.0.1:15100/healthz]
interval: 5s interval: 5s
@@ -131,6 +133,7 @@ services:
FRONTEND_SHARED_ASSET_PUBLIC_PATH: /gateway/profile-assets FRONTEND_SHARED_ASSET_PUBLIC_PATH: /gateway/profile-assets
FRONTEND_READINESS_ORIGIN: http://caddy FRONTEND_READINESS_ORIGIN: http://caddy
RELEASE_BUILDER_URL: http://builder:15100 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} RELEASE_BUILD_NODE_OPTIONS: ${BUILDER_NODE_OPTIONS:---max-old-space-size=3072}
GATEWAY_TRPC_PATH: /gateway/api/trpc GATEWAY_TRPC_PATH: /gateway/api/trpc
CORE_NAVIGATION_CONFIG_FILE: /srv/data/navigation.json CORE_NAVIGATION_CONFIG_FILE: /srv/data/navigation.json
@@ -154,6 +157,7 @@ services:
volumes: volumes:
- core-source:/srv/core - core-source:/srv/core
- runtime-data:/srv/data - 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 - 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_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 - ${IMAGE_SYNC_CORE2026_SECRET_FILE:?set IMAGE_SYNC_CORE2026_SECRET_FILE in .env}:/run/secrets/image_sync_core2026_secret:ro
+1 -1
View File
@@ -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/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh
COPY --chmod=755 runtime/git-askpass.sh /opt/sammo/git-askpass.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 --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 WORKDIR /srv/core/repository
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"] ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"]
+59 -38
View File
@@ -1,57 +1,78 @@
const builderUrl = process.env.RELEASE_BUILDER_URL ?? 'http://builder:15100'; import {
const coreRoot = process.env.GATEWAY_WORKSPACE_ROOT ?? '/srv/core/repository'; 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( const publicBuildEnv = Object.fromEntries(
Object.entries(process.env).filter( Object.entries(process.env).filter(
([name, value]) => ([name, value]) =>
typeof value === 'string' && 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), /^(?: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) { if (process.env.RELEASE_BUILD_NODE_OPTIONS) {
publicBuildEnv.NODE_OPTIONS = process.env.RELEASE_BUILD_NODE_OPTIONS; publicBuildEnv.NODE_OPTIONS = process.env.RELEASE_BUILD_NODE_OPTIONS;
} }
delete publicBuildEnv.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 = [ const commands = [
pnpm('install', '--frozen-lockfile'), pnpm("install", "--frozen-lockfile"),
pnpm('--filter', '@sammo-ts/common', 'build'), pnpm("--filter", "@sammo-ts/common", "build"),
pnpm('--filter', '@sammo-ts/infra', 'prisma:generate'), pnpm("--filter", "@sammo-ts/infra", "prisma:generate"),
pnpm('--filter', '@sammo-ts/infra', 'build'), pnpm("--filter", "@sammo-ts/infra", "build"),
pnpm('--filter', '@sammo-ts/logic', 'build'), pnpm("--filter", "@sammo-ts/logic", "build"),
pnpm('--filter', '@sammo-ts/game-engine', 'build'), pnpm("--filter", "@sammo-ts/game-engine", "build"),
pnpm('--filter', '@sammo-ts/gateway-api', 'build'), pnpm("--filter", "@sammo-ts/gateway-api", "build"),
pnpm('--filter', '@sammo-ts/gateway-frontend', 'build'), pnpm("--filter", "@sammo-ts/gateway-frontend", "build"),
pnpm('--filter', '@sammo-ts/release-controller', 'build'), pnpm("--filter", "@sammo-ts/release-controller", "build"),
]; ];
const response = await fetch(new URL('/v1/builds', builderUrl), { const response = await fetch(new URL("/v1/builds", builderUrl), {
method: 'POST', method: "POST",
headers: { 'content-type': 'application/json' }, 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 }), 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; let result = null;
const handleLine = (line) => { for await (const message of readBuilderMessages(response)) {
if (!line.trim()) return; if (message.event?.type === "OUTPUT") {
const message = JSON.parse(line); if (
if (message.event?.type === 'OUTPUT') { !["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`; 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); else process.stdout.write(output);
} }
if (message.result) result = message.result; if (message.result) {
}; if (
for await (const chunk of response.body) { typeof message.result.ok !== "boolean" ||
buffer += decoder.decode(chunk, { stream: true }); (message.result.ok &&
const lines = buffer.split('\n'); (message.result.exitCode !== 0 || message.result.aborted === true)) ||
buffer = lines.pop() ?? ''; typeof message.result.output !== "string" ||
for (const line of lines) handleLine(line); message.result.output.length > 64 * 1024
} )
handleLine(buffer); throw new Error("Invalid release build result.");
if (!result?.ok) { result = message.result;
throw new Error(result?.output || 'Release builder closed without a successful result.'); break;
}
} }
if (!result?.ok)
throw new Error("Release builder did not produce a successful result.");
+223
View File
@@ -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();
}
}
+322 -163
View File
@@ -1,60 +1,25 @@
import { spawn } from 'node:child_process'; import { spawn } from "node:child_process";
import http from 'node:http'; import { createHash, timingSafeEqual } from "node:crypto";
import path from 'node:path'; import http from "node:http";
import { fileURLToPath } from "node:url";
const port = Number(process.env.RELEASE_BUILDER_PORT ?? 15100); import path from "node:path";
const allowedRoots = [ import {
'/srv/core/repository', BUILD_TIMEOUT_MS,
'/srv/core/profile-worktrees', MAX_BUILD_FRAME_BYTES,
'/srv/core/release-worktrees', readBuilderToken,
'/workspace/core2026', validateBuildCommand,
].map((root) => path.resolve(root)); } from "./release-builder-security.mjs";
const allowedEnvName = /^(?:CI|NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/;
const maxOutput = 64 * 1024;
let queue = Promise.resolve();
const appendTail = (current, chunk) => `${current}${String(chunk)}`.slice(-maxOutput);
const isWithin = (candidate, root) => {
const relative = path.relative(root, candidate);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
};
const validateCommand = (raw) => {
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.args) || typeof raw.cwd !== 'string') {
throw new Error('Each release build command must provide command, args, and cwd.');
}
const cwd = path.resolve(raw.cwd);
if (!allowedRoots.some((root) => isWithin(cwd, root))) {
throw new Error(`Release build cwd is outside the shared Core workspaces: ${cwd}`);
}
const args = raw.args.map((value) => String(value));
if (raw.command === 'pnpm') {
const install = args[0] === 'install' && args.includes('--frozen-lockfile');
const turbo = args[0] === 'exec' && args[1] === 'turbo' && args[2] === 'run';
const packageTask = args[0] === '--filter' && args.length >= 3 && ['build', 'prisma:generate'].includes(args[2]);
if (!install && !turbo && !packageTask) throw new Error('Unsupported pnpm release build command.');
} else if (raw.command === 'node') {
if (args[0] !== 'tools/build-scripts/materialize-profile-frontend.mjs' || args.length !== 2) {
throw new Error('Unsupported Node release build command.');
}
} else {
throw new Error(`Unsupported release build executable: ${raw.command}`);
}
const overrides = Object.fromEntries(
Object.entries(raw.env ?? {}).filter(
([name, value]) => allowedEnvName.test(name) && typeof value === 'string',
),
);
return {
command: raw.command,
args,
cwd,
env: { ...process.env, ...overrides },
eventCommand: { command: raw.command, args, cwd, env: overrides },
};
};
const 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) => { const terminate = (child, signal) => {
if (!child.pid) return; if (!child.pid) return;
try { try {
@@ -63,120 +28,314 @@ const terminate = (child, signal) => {
try { try {
child.kill(signal); child.kill(signal);
} catch { } catch {
// The build already exited. /* Already exited. */
} }
} }
}; };
const runCommand = (command, emit, signal) => const readJson = (request, timeoutMs) =>
new Promise((resolve) => { new Promise((resolve, reject) => {
emit({ event: { type: 'COMMAND_START', command: command.eventCommand } }); const body = Buffer.alloc(256 * 1024);
const child = spawn(command.command, command.args, { let size = 0;
cwd: command.cwd, const finish = (error, value) => {
env: command.env, clearTimeout(timer);
stdio: ['ignore', 'pipe', 'pipe'], request
detached: true, .off("data", data)
}); .off("end", end)
let output = ''; .off("error", fail)
let aborted = false; .off("aborted", fail);
let spawnError = null; if (error) reject(error);
const abort = () => { else resolve(value);
if (aborted) return;
aborted = true;
terminate(child, 'SIGTERM');
const killTimer = setTimeout(() => terminate(child, 'SIGKILL'), 5000);
killTimer.unref();
}; };
signal.addEventListener('abort', abort, { once: true }); const fail = () =>
for (const stream of ['stdout', 'stderr']) { finish(
child[stream].on('data', (chunk) => { Object.assign(new Error("Build request interrupted."), { status: 400 }),
output = appendTail(output, chunk); );
for (const message of String(chunk).split(/\r?\n/).filter(Boolean)) { const data = (chunk) => {
emit({ event: { type: 'OUTPUT', stream, message: message.slice(0, 2000) } }); if (size + chunk.length > body.length) {
} request.pause();
}); finish(
} Object.assign(new Error("Build request is too large."), {
child.on('error', (error) => { status: 413,
spawnError = error; }),
output = appendTail(output, error.message); );
}); return;
child.on('close', (exitCode) => { }
signal.removeEventListener('abort', abort); chunk.copy(body, size);
emit({ event: { type: 'COMMAND_END', command: command.eventCommand, exitCode: spawnError ? null : exitCode } }); size += chunk.length;
resolve({ };
ok: !aborted && !spawnError && exitCode === 0, const end = () => {
exitCode: spawnError ? null : exitCode, try {
output, finish(
...(aborted ? { aborted: true } : {}), 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) => { export const createReleaseBuilderServer = ({
let output = ''; token,
for (const command of commands) { allowedRoots = roots,
if (signal.aborted) return { ok: false, exitCode: null, output, aborted: true }; cacheRoots = caches,
const result = await runCommand(command, emit, signal); spawnImpl = spawn,
output = appendTail(output, result.output); maxJobs = 3,
if (!result.ok) return { ...result, output }; jobTimeoutMs = BUILD_TIMEOUT_MS,
} bodyTimeoutMs = 5000,
return { ok: true, exitCode: 0, output }; terminateGraceMs = 5000,
}; baseEnv = process.env,
}) => {
const readJson = async (request) => { if (!/^[A-Za-z0-9_-]{43,128}$/.test(token ?? ""))
let body = ''; throw new Error("A valid builder token is required.");
for await (const chunk of request) { const tokenDigest = digest(token);
body += chunk; // Repository scripts are trusted release inputs; unrelated process secrets are excluded.
if (body.length > 256 * 1024) throw new Error('Release build request is too large.'); const childBaseEnv = Object.fromEntries(
} Object.entries(baseEnv).filter(
return JSON.parse(body); ([name, value]) =>
}; typeof value === "string" &&
[
const server = http.createServer(async (request, response) => { "PATH",
if (request.method === 'GET' && request.url === '/healthz') { "HOME",
response.writeHead(200, { 'content-type': 'text/plain' }); "LANG",
response.end('ok'); "TZ",
return; "CI",
} "NODE_OPTIONS",
if (request.method !== 'POST' || request.url !== '/v1/builds') { "PROFILE_FRONTEND_BUILD_NODE_OPTIONS",
response.writeHead(404).end(); "RAYON_NUM_THREADS",
return; "RELEASE_TURBO_CONCURRENCY",
} "TURBO_CACHE_DIR",
try { "XDG_DATA_HOME",
const payload = await readJson(request); ].includes(name),
if (!Array.isArray(payload.commands) || payload.commands.length === 0 || payload.commands.length > 12) { ),
throw new Error('Release build request must contain 1-12 commands.'); );
} let jobs = 0;
const commands = payload.commands.map(validateCommand); let queue = Promise.resolve();
response.writeHead(200, { const controllers = new Set();
'content-type': 'application/x-ndjson', const children = new Set();
'cache-control': 'no-store', const runCommand = (command, emit, signal) =>
'x-content-type-options': 'nosniff', 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 server = http.createServer(async (request, response) => {
const abortController = new AbortController(); request.on("error", () => {});
request.once('aborted', () => abortController.abort()); const reject = (status) => {
response.once('close', () => { response.writeHead(status, {
if (!response.writableEnded) abortController.abort(); "content-type": "text/plain",
}); connection: "close",
const emit = (message) => { ...(status === 429 ? { "retry-after": "1" } : {}),
if (!response.writableEnded) response.write(`${JSON.stringify(message)}\n`); });
response.end(`Release builder request rejected (${status}).`);
}; };
const execution = queue.then(() => runJob(commands, emit, abortController.signal)); if (request.method === "GET" && request.url === "/healthz") {
queue = execution.then(() => undefined, () => undefined); response.writeHead(200, { "content-type": "text/plain" }).end("ok");
const result = await execution; return;
emit({ result }); }
response.end(); if (request.method !== "POST" || request.url !== "/v1/builds") {
} catch (error) { reject(404);
if (!response.headersSent) response.writeHead(400, { 'content-type': 'text/plain' }); return;
response.end(error instanceof Error ? error.message : String(error)); }
} 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', () => { if (
console.log(`[release-builder] listening on ${port}`); process.argv[1] &&
}); path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
const shutdown = () => server.close(() => process.exit(0)); const server = createReleaseBuilderServer({
process.once('SIGINT', shutdown); token: readBuilderToken(process.env.RELEASE_BUILDER_TOKEN_FILE),
process.once('SIGTERM', shutdown); });
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);
}
+9 -1
View File
@@ -14,6 +14,14 @@ export const validateComposeModel = (model, mode) => {
const caddy = model?.services?.caddy; const caddy = model?.services?.caddy;
if (!builder) errors.push('builder service is missing'); if (!builder) errors.push('builder service is missing');
if (!caddy) errors.push('caddy 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 = { const requiredImageEnvironment = {
GATEWAY_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret', 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'); errors.push('builder Rayon thread count must be between 1 and 4');
} }
for (const name of Object.keys(builder.environment ?? {})) { 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}`); errors.push(`builder must not receive sensitive environment variable ${name}`);
} }
} }
+1
View File
@@ -12,6 +12,7 @@ const REQUIRED = [
'INITIAL_ADMIN_USERNAME', 'INITIAL_ADMIN_USERNAME',
'INITIAL_ADMIN_PASSWORD', 'INITIAL_ADMIN_PASSWORD',
'KAKAO_REST_KEY', 'KAKAO_REST_KEY',
'RELEASE_BUILDER_TOKEN_FILE',
]; ];
const MIN_LENGTH = new Map([ const MIN_LENGTH = new Map([
+5 -1
View File
@@ -10,7 +10,7 @@ if [ ! -f "$env_file" ]; then
exit 66 exit 66
fi 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 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" secret_file_keys="$secret_file_keys WEB_PUSH_VAPID_PRIVATE_KEY_FILE"
fi fi
@@ -33,6 +33,10 @@ for key in $secret_file_keys; do
echo "$key must not use the tracked example placeholder." >&2 echo "$key must not use the tracked example placeholder." >&2
exit 66 exit 66
fi 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 done
docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \ docker run --rm --network=none --memory=128m --memory-swap=128m --cpus=1 --pids-limit=64 --env-file "$env_file" \
+301
View File
@@ -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();
}
});
+6 -1
View File
@@ -6,6 +6,7 @@ import { validateComposeModel } from '../runtime/validate-compose-model.mjs';
const safeRuntime = { const safeRuntime = {
environment: { environment: {
CORE_SOURCE_MODE: 'clone', CORE_SOURCE_MODE: 'clone',
RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token',
NODE_OPTIONS: '--max-old-space-size=1536', NODE_OPTIONS: '--max-old-space-size=1536',
TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=3072', TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=3072',
RAYON_NUM_THREADS: '2', RAYON_NUM_THREADS: '2',
@@ -28,6 +29,7 @@ const safeRuntime = {
GATEWAY_ACTIVE_RELEASE_GIT_REF: 'refs/sammo/active-gateway', GATEWAY_ACTIVE_RELEASE_GIT_REF: 'refs/sammo/active-gateway',
}, },
volumes: [ volumes: [
{ type: 'bind', source: '/secrets/release_builder_token', target: '/run/secrets/release_builder_token', read_only: true },
{ {
type: 'bind', type: 'bind',
source: '/secrets/image_upload_core2026_secret', source: '/secrets/image_upload_core2026_secret',
@@ -61,7 +63,9 @@ const safeRuntime = {
}; };
const safeBuilder = { const safeBuilder = {
volumes: [{ type: 'bind', source: '/secrets/release_builder_token', target: '/run/secrets/release_builder_token', read_only: true }],
environment: { environment: {
RELEASE_BUILDER_TOKEN_FILE: '/run/secrets/release_builder_token',
NODE_OPTIONS: '--max-old-space-size=3072', NODE_OPTIONS: '--max-old-space-size=3072',
RAYON_NUM_THREADS: '2', RAYON_NUM_THREADS: '2',
}, },
@@ -145,7 +149,8 @@ test('rejects missing or excessive build memory and parallelism limits', () => {
...safeRuntime, ...safeRuntime,
environment: { environment: {
CORE_SOURCE_MODE: 'clone', 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', TURN_DAEMON_NODE_OPTIONS: '--max-old-space-size=8192',
RAYON_NUM_THREADS: '8', RAYON_NUM_THREADS: '8',
}, },
+1
View File
@@ -14,6 +14,7 @@ const validEnv = {
INITIAL_ADMIN_USERNAME: 'admin', INITIAL_ADMIN_USERNAME: 'admin',
INITIAL_ADMIN_PASSWORD: 'a'.repeat(16), INITIAL_ADMIN_PASSWORD: 'a'.repeat(16),
KAKAO_REST_KEY: 'kakao-test-key', KAKAO_REST_KEY: 'kakao-test-key',
RELEASE_BUILDER_TOKEN_FILE: '/secrets/release_builder_token',
}; };
test('accepts a complete public repository environment', () => { test('accepts a complete public repository environment', () => {