feat(runtime): 정적 프런트엔드와 격리 빌더를 구성한다
Caddy가 불변 아티팩트를 직접 제공하고 runtime과 builder의 자원·비밀 경계를 분리한다. Gateway active release ref를 재기동에 복원하고 production/development/smoke 모델 검증을 확장한다.
This commit is contained in:
+3
-2
@@ -4,12 +4,13 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates curl git openssh-client procps tini \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& corepack enable \
|
||||
&& corepack prepare pnpm@11.17.0 --activate
|
||||
&& corepack prepare pnpm@11.21.0 --activate
|
||||
|
||||
COPY --chmod=755 runtime/entrypoint.sh /usr/local/bin/sammo-entrypoint
|
||||
COPY --chmod=755 runtime/redis-entrypoint.sh /opt/sammo/redis-entrypoint.sh
|
||||
COPY --chmod=755 runtime/git-askpass.sh /opt/sammo/git-askpass.sh
|
||||
COPY runtime/ecosystem.config.cjs runtime/bootstrap.mjs runtime/validate-env.mjs /opt/sammo/
|
||||
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/
|
||||
|
||||
WORKDIR /srv/core/repository
|
||||
ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/sammo-entrypoint"]
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
core_root=${1:?usage: checkout-active-release.sh CORE_ROOT RELEASE_REF}
|
||||
active_release_ref=${2:-}
|
||||
|
||||
if [ -z "$active_release_ref" ]; then
|
||||
exit 0
|
||||
fi
|
||||
case "$active_release_ref" in
|
||||
refs/sammo/*) ;;
|
||||
*)
|
||||
echo 'GATEWAY_ACTIVE_RELEASE_GIT_REF must be below refs/sammo/' >&2
|
||||
exit 64
|
||||
;;
|
||||
esac
|
||||
if ! git check-ref-format "$active_release_ref" >/dev/null 2>&1; then
|
||||
echo 'GATEWAY_ACTIVE_RELEASE_GIT_REF is not a valid Git ref' >&2
|
||||
exit 64
|
||||
fi
|
||||
if ! git -C "$core_root" show-ref --verify --quiet "$active_release_ref"; then
|
||||
exit 0
|
||||
fi
|
||||
if ! git -C "$core_root" diff --quiet || ! git -C "$core_root" diff --cached --quiet; then
|
||||
echo 'Core bootstrap checkout has tracked changes; refusing to switch the active release ref' >&2
|
||||
exit 65
|
||||
fi
|
||||
|
||||
active_release_sha=$(git -C "$core_root" rev-parse --verify "${active_release_ref}^{commit}")
|
||||
git -C "$core_root" checkout --quiet --detach "$active_release_sha"
|
||||
echo "Using persistent Gateway release $active_release_sha for runtime bootstrap."
|
||||
@@ -19,13 +19,6 @@ module.exports = {
|
||||
script: path.join(root, 'app/gateway-api/dist/index.js'),
|
||||
env: { ...process.env, GATEWAY_ROLE: 'api' },
|
||||
},
|
||||
{
|
||||
...common,
|
||||
name: 'sammo:gateway-frontend',
|
||||
cwd: path.join(root, 'app/gateway-frontend'),
|
||||
script: path.join(root, 'app/gateway-frontend/node_modules/vite/bin/vite.js'),
|
||||
args: 'preview --host 0.0.0.0 --port 15000',
|
||||
},
|
||||
{
|
||||
...common,
|
||||
name: 'sammo:gateway-orchestrator',
|
||||
|
||||
@@ -69,6 +69,7 @@ case "${CORE_SOURCE_MODE:-clone}" in
|
||||
esac
|
||||
|
||||
git config --global --add safe.directory "$core_root"
|
||||
/opt/sammo/checkout-active-release.sh "$core_root" "${GATEWAY_ACTIVE_RELEASE_GIT_REF:-}"
|
||||
export GATEWAY_WORKSPACE_ROOT="$core_root"
|
||||
export RELEASE_CONTROLLER_WORKSPACE_ROOT="$core_root"
|
||||
|
||||
@@ -85,7 +86,6 @@ export GATEWAY_DATABASE_URL="$DATABASE_URL"
|
||||
export REDIS_URL="$(node -e 'const u=new URL("redis://localhost/0");u.password=process.env.REDIS_PASSWORD;u.hostname=process.env.REDIS_HOST||"redis";u.port=process.env.REDIS_PORT||"6379";process.stdout.write(u.href)')"
|
||||
|
||||
cd "$core_root"
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
if [ "${RUNTIME_MODE:-production}" = development ]; then
|
||||
pnpm --filter @sammo-ts/infra prisma:generate
|
||||
@@ -96,16 +96,16 @@ if [ "${RUNTIME_MODE:-production}" = development ]; then
|
||||
exec sleep infinity
|
||||
fi
|
||||
|
||||
pnpm --filter @sammo-ts/common build
|
||||
pnpm --filter @sammo-ts/infra prisma:generate
|
||||
pnpm --filter @sammo-ts/infra build
|
||||
pnpm --filter @sammo-ts/logic build
|
||||
pnpm --filter @sammo-ts/game-engine build
|
||||
pnpm --filter @sammo-ts/gateway-api build
|
||||
pnpm --filter @sammo-ts/gateway-frontend build
|
||||
pnpm --filter @sammo-ts/release-controller build
|
||||
node /opt/sammo/release-builder-client.mjs
|
||||
pnpm --filter @sammo-ts/infra prisma:migrate:deploy:gateway
|
||||
|
||||
core_commit_sha=$(git rev-parse HEAD)
|
||||
node tools/build-scripts/publish-frontend-artifact.mjs \
|
||||
--artifact-root "${FRONTEND_ARTIFACT_ROOT:-/srv/frontend-artifacts}" \
|
||||
--frontend-key gateway \
|
||||
--source-root "$core_root/app/gateway-frontend/dist" \
|
||||
--commit-sha "$core_commit_sha"
|
||||
|
||||
GATEWAY_ROLE=api node app/gateway-api/dist/index.js &
|
||||
bootstrap_pid=$!
|
||||
cleanup_bootstrap() {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
const builderUrl = process.env.RELEASE_BUILDER_URL ?? 'http://builder:15100';
|
||||
const coreRoot = process.env.GATEWAY_WORKSPACE_ROOT ?? '/srv/core/repository';
|
||||
const publicBuildEnv = Object.fromEntries(
|
||||
Object.entries(process.env).filter(
|
||||
([name, value]) =>
|
||||
typeof value === 'string' &&
|
||||
/^(?:CI|NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/.test(name),
|
||||
),
|
||||
);
|
||||
if (process.env.RELEASE_BUILD_NODE_OPTIONS) {
|
||||
publicBuildEnv.NODE_OPTIONS = process.env.RELEASE_BUILD_NODE_OPTIONS;
|
||||
}
|
||||
delete publicBuildEnv.RELEASE_BUILD_NODE_OPTIONS;
|
||||
const pnpm = (...args) => ({ command: 'pnpm', args, cwd: coreRoot, env: publicBuildEnv });
|
||||
const commands = [
|
||||
pnpm('install', '--frozen-lockfile'),
|
||||
pnpm('--filter', '@sammo-ts/common', 'build'),
|
||||
pnpm('--filter', '@sammo-ts/infra', 'prisma:generate'),
|
||||
pnpm('--filter', '@sammo-ts/infra', 'build'),
|
||||
pnpm('--filter', '@sammo-ts/logic', 'build'),
|
||||
pnpm('--filter', '@sammo-ts/game-engine', 'build'),
|
||||
pnpm('--filter', '@sammo-ts/gateway-api', 'build'),
|
||||
pnpm('--filter', '@sammo-ts/gateway-frontend', 'build'),
|
||||
pnpm('--filter', '@sammo-ts/release-controller', 'build'),
|
||||
];
|
||||
|
||||
const response = await fetch(new URL('/v1/builds', builderUrl), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ commands }),
|
||||
});
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error((await response.text()) || `Release builder returned HTTP ${response.status}.`);
|
||||
}
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let result = null;
|
||||
const handleLine = (line) => {
|
||||
if (!line.trim()) return;
|
||||
const message = JSON.parse(line);
|
||||
if (message.event?.type === 'OUTPUT') {
|
||||
const output = `${message.event.message}\n`;
|
||||
if (message.event.stream === 'stderr') process.stderr.write(output);
|
||||
else process.stdout.write(output);
|
||||
}
|
||||
if (message.result) result = message.result;
|
||||
};
|
||||
for await (const chunk of response.body) {
|
||||
buffer += decoder.decode(chunk, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
for (const line of lines) handleLine(line);
|
||||
}
|
||||
handleLine(buffer);
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.output || 'Release builder closed without a successful result.');
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import http from 'node:http';
|
||||
import path from 'node:path';
|
||||
|
||||
const port = Number(process.env.RELEASE_BUILDER_PORT ?? 15100);
|
||||
const allowedRoots = [
|
||||
'/srv/core/repository',
|
||||
'/srv/core/profile-worktrees',
|
||||
'/srv/core/release-worktrees',
|
||||
'/workspace/core2026',
|
||||
].map((root) => path.resolve(root));
|
||||
const allowedEnvName = /^(?:CI|NODE_OPTIONS|PROFILE_FRONTEND_BUILD_NODE_OPTIONS|RAYON_NUM_THREADS|RELEASE_TURBO_CONCURRENCY|TURBO_CACHE_DIR|TZ|VITE_[A-Z0-9_]+)$/;
|
||||
const maxOutput = 64 * 1024;
|
||||
let queue = Promise.resolve();
|
||||
|
||||
const appendTail = (current, chunk) => `${current}${String(chunk)}`.slice(-maxOutput);
|
||||
|
||||
const isWithin = (candidate, root) => {
|
||||
const relative = path.relative(root, candidate);
|
||||
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
||||
};
|
||||
|
||||
const validateCommand = (raw) => {
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray(raw.args) || typeof raw.cwd !== 'string') {
|
||||
throw new Error('Each release build command must provide command, args, and cwd.');
|
||||
}
|
||||
const cwd = path.resolve(raw.cwd);
|
||||
if (!allowedRoots.some((root) => isWithin(cwd, root))) {
|
||||
throw new Error(`Release build cwd is outside the shared Core workspaces: ${cwd}`);
|
||||
}
|
||||
const args = raw.args.map((value) => String(value));
|
||||
if (raw.command === 'pnpm') {
|
||||
const install = args[0] === 'install' && args.includes('--frozen-lockfile');
|
||||
const turbo = args[0] === 'exec' && args[1] === 'turbo' && args[2] === 'run';
|
||||
const packageTask = args[0] === '--filter' && args.length >= 3 && ['build', 'prisma:generate'].includes(args[2]);
|
||||
if (!install && !turbo && !packageTask) throw new Error('Unsupported pnpm release build command.');
|
||||
} else if (raw.command === 'node') {
|
||||
if (args[0] !== 'tools/build-scripts/materialize-profile-frontend.mjs' || args.length !== 2) {
|
||||
throw new Error('Unsupported Node release build command.');
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Unsupported release build executable: ${raw.command}`);
|
||||
}
|
||||
const overrides = Object.fromEntries(
|
||||
Object.entries(raw.env ?? {}).filter(
|
||||
([name, value]) => allowedEnvName.test(name) && typeof value === 'string',
|
||||
),
|
||||
);
|
||||
return {
|
||||
command: raw.command,
|
||||
args,
|
||||
cwd,
|
||||
env: { ...process.env, ...overrides },
|
||||
eventCommand: { command: raw.command, args, cwd, env: overrides },
|
||||
};
|
||||
};
|
||||
|
||||
const terminate = (child, signal) => {
|
||||
if (!child.pid) return;
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
} catch {
|
||||
try {
|
||||
child.kill(signal);
|
||||
} catch {
|
||||
// The build already exited.
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const runCommand = (command, emit, signal) =>
|
||||
new Promise((resolve) => {
|
||||
emit({ event: { type: 'COMMAND_START', command: command.eventCommand } });
|
||||
const child = spawn(command.command, command.args, {
|
||||
cwd: command.cwd,
|
||||
env: command.env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
detached: true,
|
||||
});
|
||||
let output = '';
|
||||
let aborted = false;
|
||||
let spawnError = null;
|
||||
const abort = () => {
|
||||
if (aborted) return;
|
||||
aborted = true;
|
||||
terminate(child, 'SIGTERM');
|
||||
const killTimer = setTimeout(() => terminate(child, 'SIGKILL'), 5000);
|
||||
killTimer.unref();
|
||||
};
|
||||
signal.addEventListener('abort', abort, { once: true });
|
||||
for (const stream of ['stdout', 'stderr']) {
|
||||
child[stream].on('data', (chunk) => {
|
||||
output = appendTail(output, chunk);
|
||||
for (const message of String(chunk).split(/\r?\n/).filter(Boolean)) {
|
||||
emit({ event: { type: 'OUTPUT', stream, message: message.slice(0, 2000) } });
|
||||
}
|
||||
});
|
||||
}
|
||||
child.on('error', (error) => {
|
||||
spawnError = error;
|
||||
output = appendTail(output, error.message);
|
||||
});
|
||||
child.on('close', (exitCode) => {
|
||||
signal.removeEventListener('abort', abort);
|
||||
emit({ event: { type: 'COMMAND_END', command: command.eventCommand, exitCode: spawnError ? null : exitCode } });
|
||||
resolve({
|
||||
ok: !aborted && !spawnError && exitCode === 0,
|
||||
exitCode: spawnError ? null : exitCode,
|
||||
output,
|
||||
...(aborted ? { aborted: true } : {}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const runJob = async (commands, emit, signal) => {
|
||||
let output = '';
|
||||
for (const command of commands) {
|
||||
if (signal.aborted) return { ok: false, exitCode: null, output, aborted: true };
|
||||
const result = await runCommand(command, emit, signal);
|
||||
output = appendTail(output, result.output);
|
||||
if (!result.ok) return { ...result, output };
|
||||
}
|
||||
return { ok: true, exitCode: 0, output };
|
||||
};
|
||||
|
||||
const readJson = async (request) => {
|
||||
let body = '';
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
if (body.length > 256 * 1024) throw new Error('Release build request is too large.');
|
||||
}
|
||||
return JSON.parse(body);
|
||||
};
|
||||
|
||||
const server = http.createServer(async (request, response) => {
|
||||
if (request.method === 'GET' && request.url === '/healthz') {
|
||||
response.writeHead(200, { 'content-type': 'text/plain' });
|
||||
response.end('ok');
|
||||
return;
|
||||
}
|
||||
if (request.method !== 'POST' || request.url !== '/v1/builds') {
|
||||
response.writeHead(404).end();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const payload = await readJson(request);
|
||||
if (!Array.isArray(payload.commands) || payload.commands.length === 0 || payload.commands.length > 12) {
|
||||
throw new Error('Release build request must contain 1-12 commands.');
|
||||
}
|
||||
const commands = payload.commands.map(validateCommand);
|
||||
response.writeHead(200, {
|
||||
'content-type': 'application/x-ndjson',
|
||||
'cache-control': 'no-store',
|
||||
'x-content-type-options': 'nosniff',
|
||||
});
|
||||
response.flushHeaders();
|
||||
const abortController = new AbortController();
|
||||
request.once('aborted', () => abortController.abort());
|
||||
response.once('close', () => {
|
||||
if (!response.writableEnded) abortController.abort();
|
||||
});
|
||||
const emit = (message) => {
|
||||
if (!response.writableEnded) response.write(`${JSON.stringify(message)}\n`);
|
||||
};
|
||||
const execution = queue.then(() => runJob(commands, emit, abortController.signal));
|
||||
queue = execution.then(() => undefined, () => undefined);
|
||||
const result = await execution;
|
||||
emit({ result });
|
||||
response.end();
|
||||
} catch (error) {
|
||||
if (!response.headersSent) response.writeHead(400, { 'content-type': 'text/plain' });
|
||||
response.end(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(port, '0.0.0.0', () => {
|
||||
console.log(`[release-builder] listening on ${port}`);
|
||||
});
|
||||
|
||||
const shutdown = () => server.close(() => process.exit(0));
|
||||
process.once('SIGINT', shutdown);
|
||||
process.once('SIGTERM', shutdown);
|
||||
@@ -10,6 +10,10 @@ export const validateComposeModel = (model, mode) => {
|
||||
const errors = [];
|
||||
const runtime = model?.services?.runtime;
|
||||
if (!runtime) return ['runtime service is missing'];
|
||||
const builder = model?.services?.builder;
|
||||
const caddy = model?.services?.caddy;
|
||||
if (!builder) errors.push('builder service is missing');
|
||||
if (!caddy) errors.push('caddy service is missing');
|
||||
|
||||
const requiredImageEnvironment = {
|
||||
GATEWAY_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret',
|
||||
@@ -41,6 +45,16 @@ export const validateComposeModel = (model, mode) => {
|
||||
errors.push(`${target} must be a read-only bind mount`);
|
||||
}
|
||||
}
|
||||
const artifactMount = mounts.find((candidate) => candidate?.target === '/srv/frontend-artifacts');
|
||||
if (!artifactMount || artifactMount.read_only === true) {
|
||||
errors.push('/srv/frontend-artifacts must be a writable runtime volume');
|
||||
}
|
||||
const caddyArtifactMount = (Array.isArray(caddy?.volumes) ? caddy.volumes : []).find(
|
||||
(candidate) => candidate?.target === '/srv/frontend-artifacts',
|
||||
);
|
||||
if (!caddyArtifactMount || caddyArtifactMount.read_only !== true) {
|
||||
errors.push('/srv/frontend-artifacts must be a read-only Caddy volume');
|
||||
}
|
||||
|
||||
if (!positive(runtime.mem_limit)) errors.push('runtime memory limit must be positive');
|
||||
if (!positive(runtime.memswap_limit)) errors.push('runtime memory+swap limit must be positive');
|
||||
@@ -70,6 +84,34 @@ export const validateComposeModel = (model, mode) => {
|
||||
errors.push('runtime Rayon thread count must be between 1 and 4');
|
||||
}
|
||||
|
||||
if (builder) {
|
||||
if (!positive(builder.mem_limit)) errors.push('builder memory limit must be positive');
|
||||
if (!positive(builder.memswap_limit)) errors.push('builder memory+swap limit must be positive');
|
||||
if (Number(builder.memswap_limit) !== Number(builder.mem_limit)) {
|
||||
errors.push('builder memory+swap limit must equal its memory limit');
|
||||
}
|
||||
if (!positive(builder.cpus)) errors.push('builder CPU limit must be positive');
|
||||
if (!positive(builder.pids_limit)) errors.push('builder PID limit must be positive');
|
||||
const builderNodeOptions = builder.environment?.NODE_OPTIONS ?? '';
|
||||
const builderHeapMatch = /(?:^|\s)--max-old-space-size=(\d+)(?:\s|$)/.exec(builderNodeOptions);
|
||||
const builderHeapMiB = Number(builderHeapMatch?.[1] ?? 0);
|
||||
if (!builderHeapMatch || builderHeapMiB < 512 || builderHeapMiB > 3584) {
|
||||
errors.push('builder Node heap limit must be between 512 and 3584 MiB');
|
||||
}
|
||||
if (builderHeapMiB * 1024 * 1024 >= Number(builder.mem_limit)) {
|
||||
errors.push('builder Node heap limit must stay below the builder memory limit');
|
||||
}
|
||||
const builderRayonThreads = Number(builder.environment?.RAYON_NUM_THREADS ?? 0);
|
||||
if (!Number.isInteger(builderRayonThreads) || builderRayonThreads < 1 || builderRayonThreads > 4) {
|
||||
errors.push('builder Rayon thread count must be between 1 and 4');
|
||||
}
|
||||
for (const name of Object.keys(builder.environment ?? {})) {
|
||||
if (/(?:SECRET|TOKEN|PASSWORD|DATABASE_URL|REDIS_URL)/i.test(name)) {
|
||||
errors.push(`builder must not receive sensitive environment variable ${name}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'development') {
|
||||
if (runtime.environment?.RUNTIME_MODE !== 'development') {
|
||||
errors.push('development runtime mode must be literal development');
|
||||
@@ -82,9 +124,20 @@ export const validateComposeModel = (model, mode) => {
|
||||
} else if (runtime.environment?.RUNTIME_MODE === 'development') {
|
||||
errors.push(`${mode} runtime must not use development mode`);
|
||||
}
|
||||
if (mode !== 'development') {
|
||||
if (runtime.environment?.FRONTEND_SERVE_MODE !== 'static') {
|
||||
errors.push(`${mode} frontend serve mode must be static`);
|
||||
}
|
||||
if (runtime.environment?.RELEASE_BUILDER_URL !== 'http://builder:15100') {
|
||||
errors.push(`${mode} runtime must use the isolated release builder`);
|
||||
}
|
||||
if (runtime.environment?.GATEWAY_ACTIVE_RELEASE_GIT_REF !== 'refs/sammo/active-gateway') {
|
||||
errors.push(`${mode} runtime must persist the active Gateway release ref`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === 'development' || mode === 'smoke') {
|
||||
for (const name of ['postgres', 'redis', 'runtime', 'caddy']) {
|
||||
for (const name of ['postgres', 'redis', 'builder', 'runtime', 'caddy']) {
|
||||
if (model?.services?.[name]?.restart !== 'no') errors.push(`${name} ${mode} restart policy must be no`);
|
||||
}
|
||||
if (Number(runtime.mem_limit) > MAX_SAFE_DEV_MEMORY) errors.push(`${mode} runtime memory limit exceeds 4 GiB`);
|
||||
|
||||
Reference in New Issue
Block a user