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'); } 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.`); } }