VAPID private key를 Compose secret으로 주입하고 Web Push를 기본 비활성으로 전달한다. 활성화 입력과 Compose 모델 검증, 운영 README를 함께 추가한다.
177 lines
8.5 KiB
JavaScript
177 lines
8.5 KiB
JavaScript
import fs from 'node:fs';
|
|
|
|
const MAX_SAFE_DEV_MEMORY = 4 * 1024 * 1024 * 1024;
|
|
const MAX_SAFE_DEV_CPUS = 4;
|
|
const MAX_SAFE_DEV_PIDS = 256;
|
|
|
|
const positive = (value) => typeof value === 'number' ? value > 0 : Number(value) > 0;
|
|
|
|
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',
|
|
GAME_IMAGE_UPLOAD_SECRET_FILE: '/run/secrets/image_upload_core2026_secret',
|
|
IMAGE_SYNC_SECRET_FILE: '/run/secrets/image_sync_core2026_secret',
|
|
};
|
|
for (const [key, expected] of Object.entries(requiredImageEnvironment)) {
|
|
if (runtime.environment?.[key] !== expected) errors.push(`${key} must be ${expected}`);
|
|
}
|
|
for (const key of [
|
|
'GATEWAY_IMAGE_UPLOAD_URL',
|
|
'GATEWAY_SHARED_ICON_PUBLIC_URL',
|
|
'GATEWAY_USER_ICON_PUBLIC_URL',
|
|
'GAME_IMAGE_UPLOAD_URL',
|
|
'GAME_CONTENT_IMAGE_PUBLIC_URL',
|
|
'IMAGE_SYNC_URL',
|
|
'VITE_IMAGE_PUBLIC_URL',
|
|
'VITE_GATEWAY_USER_ICON_BASE_URL',
|
|
]) {
|
|
if (!runtime.environment?.[key]) errors.push(`${key} must be configured`);
|
|
}
|
|
const mounts = Array.isArray(runtime.volumes) ? runtime.volumes : [];
|
|
for (const target of [
|
|
'/run/secrets/image_upload_core2026_secret',
|
|
'/run/secrets/image_sync_core2026_secret',
|
|
]) {
|
|
const mount = mounts.find((candidate) => candidate?.target === target);
|
|
if (!mount || mount.type !== 'bind' || mount.read_only !== true) {
|
|
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');
|
|
}
|
|
if (!['true', 'false'].includes(runtime.environment?.WEB_PUSH_ENABLED)) {
|
|
errors.push('WEB_PUSH_ENABLED must be literal true or false');
|
|
}
|
|
if (runtime.environment?.WEB_PUSH_VAPID_PRIVATE_KEY_FILE !== '/run/secrets/web_push_vapid_private_key') {
|
|
errors.push('WEB_PUSH_VAPID_PRIVATE_KEY_FILE must use the mounted Compose secret');
|
|
}
|
|
const webPushSecret = (Array.isArray(runtime.secrets) ? runtime.secrets : []).find(
|
|
(candidate) => candidate?.target === '/run/secrets/web_push_vapid_private_key',
|
|
);
|
|
if (!webPushSecret) errors.push('the VAPID private key must be mounted as a runtime secret');
|
|
if (runtime.environment?.WEB_PUSH_ENABLED === 'true') {
|
|
if (!runtime.environment?.WEB_PUSH_VAPID_SUBJECT) errors.push('WEB_PUSH_VAPID_SUBJECT must be configured');
|
|
if (!runtime.environment?.WEB_PUSH_VAPID_PUBLIC_KEY) errors.push('WEB_PUSH_VAPID_PUBLIC_KEY must be configured');
|
|
}
|
|
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');
|
|
if (Number(runtime.memswap_limit) !== Number(runtime.mem_limit)) {
|
|
errors.push('runtime memory+swap limit must equal its memory limit');
|
|
}
|
|
if (!positive(runtime.cpus)) errors.push('runtime CPU limit must be positive');
|
|
if (!positive(runtime.pids_limit)) errors.push('runtime PID limit must be positive');
|
|
|
|
const nodeOptions = runtime.environment?.NODE_OPTIONS ?? '';
|
|
const heapMatch = /(?:^|\s)--max-old-space-size=(\d+)(?:\s|$)/.exec(nodeOptions);
|
|
const heapMiB = Number(heapMatch?.[1] ?? 0);
|
|
if (!heapMatch || heapMiB < 512 || heapMiB > 2048) {
|
|
errors.push('runtime Node heap limit must be between 512 and 2048 MiB');
|
|
}
|
|
const turnDaemonNodeOptions = runtime.environment?.TURN_DAEMON_NODE_OPTIONS ?? '';
|
|
const turnDaemonHeapMatch = /(?:^|\s)--max-old-space-size=(\d+)(?:\s|$)/.exec(turnDaemonNodeOptions);
|
|
const turnDaemonHeapMiB = Number(turnDaemonHeapMatch?.[1] ?? 0);
|
|
if (!turnDaemonHeapMatch || turnDaemonHeapMiB < 512 || turnDaemonHeapMiB > 4096) {
|
|
errors.push('turn daemon Node heap limit must be between 512 and 4096 MiB');
|
|
}
|
|
if (turnDaemonHeapMiB * 1024 * 1024 >= Number(runtime.mem_limit)) {
|
|
errors.push('turn daemon Node heap limit must stay below the runtime memory limit');
|
|
}
|
|
const rayonThreads = Number(runtime.environment?.RAYON_NUM_THREADS ?? 0);
|
|
if (!Number.isInteger(rayonThreads) || rayonThreads < 1 || rayonThreads > 4) {
|
|
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');
|
|
}
|
|
if (runtime.environment?.CORE_SOURCE_MODE !== 'bind') {
|
|
errors.push('development Core source mode must be bind');
|
|
}
|
|
} else if (runtime.environment?.CORE_SOURCE_MODE !== 'clone') {
|
|
errors.push(`${mode} Core source mode must be clone`);
|
|
} 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', '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`);
|
|
if (Number(runtime.cpus) > MAX_SAFE_DEV_CPUS) errors.push(`${mode} runtime CPU limit exceeds 4`);
|
|
if (Number(runtime.pids_limit) > MAX_SAFE_DEV_PIDS) errors.push(`${mode} runtime PID limit exceeds 256`);
|
|
}
|
|
|
|
return errors;
|
|
};
|
|
|
|
if (process.argv[1] && process.argv[1].endsWith('validate-compose-model.mjs')) {
|
|
const mode = process.argv[2];
|
|
const model = JSON.parse(fs.readFileSync(0, 'utf8'));
|
|
const errors = validateComposeModel(model, mode);
|
|
if (errors.length) {
|
|
console.error(`Compose ${mode} safety validation failed:`);
|
|
for (const error of errors) console.error(`- ${error}`);
|
|
process.exitCode = 65;
|
|
} else {
|
|
console.log(`Compose ${mode} safety validation passed.`);
|
|
}
|
|
}
|