내부 builder 인증과 실행 허용 목록 및 자원 경계를 적용한다
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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와 종료도 검증합니다. 공개 운영의 적용
|
||||||
|
근거는 해당 환경에서 별도로 확인해야 합니다.
|
||||||
|
|||||||
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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" \
|
||||||
|
|||||||
@@ -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,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',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user