253 lines
7.2 KiB
JavaScript
253 lines
7.2 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import { spawn } from 'node:child_process';
|
|
import { readFile, unlink } from 'node:fs/promises';
|
|
import process from 'node:process';
|
|
import { validateRuntimeEnvironment } from './runtime-environment.js';
|
|
|
|
const APP_SECRET_NAMES = new Set([
|
|
'APP_ORIGIN',
|
|
'MFA_TOTP_ENCRYPTION_KEYS',
|
|
'DISCORD_CLIENT_ID',
|
|
'DISCORD_CLIENT_SECRET',
|
|
'DISCORD_REDIRECT_URI',
|
|
'OPENAI_MECHANIC_API_KEY',
|
|
'OPENAI_MECHANIC_MODEL',
|
|
'OPENAI_MECHANIC_REASONING_EFFORT',
|
|
'OPENAI_MODERATION_API_KEY',
|
|
'ORIGIN',
|
|
'PASSKEY_RP_ID',
|
|
'REMOVE_BG_API_KEY',
|
|
'REMOVE_BG_CREDIT_CHECK_HOURS',
|
|
'REMOVE_BG_LOW_CREDIT_THRESHOLD',
|
|
'REMOVE_BG_LOW_FREE_CALL_THRESHOLD',
|
|
'SMTP_FROM',
|
|
'SMTP_HOST',
|
|
'SMTP_PASSWORD',
|
|
'SMTP_PORT',
|
|
'SMTP_SECURE',
|
|
'SMTP_USER',
|
|
'TDR_AVATAR_STORAGE_PATH',
|
|
'TDR_DATABASE_PATH',
|
|
'TDR_GAME_ICON_STORAGE_PATH',
|
|
'TURNSTILE_SECRET_KEY',
|
|
'TURNSTILE_SITE_KEY',
|
|
]);
|
|
const SHARED_SECRET_NAMES = new Set(['SCHEDULER_TOKEN']);
|
|
const REMOVED_ENVIRONMENT_NAMES = [
|
|
'BASH_ENV',
|
|
'CDPATH',
|
|
'ENV',
|
|
'IFS',
|
|
'INFISICAL_ENVIRONMENT',
|
|
'INFISICAL_PROJECT_ID',
|
|
'INFISICAL_RUNTIME_SCOPE',
|
|
'INFISICAL_TOKEN',
|
|
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
|
|
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
|
|
'LD_LIBRARY_PATH',
|
|
'LD_PRELOAD',
|
|
'NODE_OPTIONS',
|
|
'NODE_PATH',
|
|
'PYTHONPATH',
|
|
'TOKEN',
|
|
];
|
|
|
|
const [runtimeScope, environment, buildNumber = '0', commitSha = '', ...rest] =
|
|
process.argv.slice(2);
|
|
const separatorIndex = rest.indexOf('--');
|
|
if (
|
|
!['app', 'scheduler'].includes(runtimeScope) ||
|
|
!['staging', 'prod'].includes(environment) ||
|
|
separatorIndex < 1
|
|
) {
|
|
console.error(
|
|
'Usage: runtime-launcher.mjs <app|scheduler> <staging|prod> <build> <sha> <secret-files...> -- <command...>',
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const secretFiles = rest.slice(0, separatorIndex);
|
|
const requestedCommand = rest.slice(separatorIndex + 1);
|
|
if (requestedCommand.length === 0) {
|
|
console.error('A runtime command is required.');
|
|
process.exit(2);
|
|
}
|
|
|
|
const expectedFileCount = runtimeScope === 'app' ? 2 : 1;
|
|
if (secretFiles.length !== expectedFileCount) {
|
|
console.error(
|
|
`${runtimeScope} requires ${expectedFileCount} secret export file(s).`,
|
|
);
|
|
process.exit(2);
|
|
}
|
|
|
|
const secrets = {};
|
|
try {
|
|
await loadSecretFile(
|
|
secretFiles[0],
|
|
runtimeScope === 'app' ? APP_SECRET_NAMES : SHARED_SECRET_NAMES,
|
|
secrets,
|
|
);
|
|
if (runtimeScope === 'app') {
|
|
await loadSecretFile(secretFiles[1], SHARED_SECRET_NAMES, secrets);
|
|
}
|
|
} finally {
|
|
const deletionResults = await Promise.allSettled(
|
|
secretFiles.map((file) => unlink(file)),
|
|
);
|
|
const undeletedFiles = secretFiles.filter(
|
|
(_, index) => deletionResults[index].status === 'rejected',
|
|
);
|
|
if (undeletedFiles.length > 0) {
|
|
throw new Error(
|
|
`Failed to delete Infisical secret export: ${undeletedFiles.join(', ')}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const childEnvironment = { ...process.env, ...secrets };
|
|
for (const name of REMOVED_ENVIRONMENT_NAMES) delete childEnvironment[name];
|
|
Object.assign(
|
|
childEnvironment,
|
|
trustedRuntimeEnvironment(runtimeScope, environment, buildNumber, commitSha),
|
|
);
|
|
|
|
const errors = validateRuntimeEnvironment(runtimeScope, childEnvironment);
|
|
if (errors.length > 0) {
|
|
console.error(
|
|
`Invalid ${runtimeScope} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
process.umask(0o007);
|
|
|
|
if (requestedCommand[0] === 'serve') {
|
|
await runAsNode(['node', 'scripts/db-migrate.mjs'], childEnvironment);
|
|
await runAsNode(['node', 'build'], childEnvironment);
|
|
} else if (requestedCommand[0] === 'scheduler') {
|
|
await runAsNode(['node', 'scripts/scheduler-client.mjs'], childEnvironment);
|
|
} else {
|
|
await runAsNode(requestedCommand, childEnvironment);
|
|
}
|
|
|
|
async function loadSecretFile(file, allowedNames, target) {
|
|
let entries;
|
|
try {
|
|
entries = JSON.parse(await readFile(file, 'utf8'));
|
|
} catch {
|
|
throw new Error('Infisical returned an unreadable secret export.');
|
|
}
|
|
if (!Array.isArray(entries)) {
|
|
throw new Error('Infisical secret export must be an array.');
|
|
}
|
|
for (const entry of entries) {
|
|
const name = entry?.key;
|
|
const value = entry?.value;
|
|
if (typeof name !== 'string' || typeof value !== 'string') {
|
|
throw new Error('Infisical secret export contains an invalid entry.');
|
|
}
|
|
if (!allowedNames.has(name)) {
|
|
throw new Error(
|
|
`Infisical secret ${name} is not allowed in this runtime scope.`,
|
|
);
|
|
}
|
|
if (Object.hasOwn(target, name)) {
|
|
throw new Error(`Infisical secret ${name} was exported more than once.`);
|
|
}
|
|
target[name] = value;
|
|
}
|
|
}
|
|
|
|
function trustedRuntimeEnvironment(
|
|
scope,
|
|
targetEnvironment,
|
|
release,
|
|
revision,
|
|
) {
|
|
const common = {
|
|
BODY_SIZE_LIMIT: '6M',
|
|
HOST: '0.0.0.0',
|
|
NODE_ENV: 'production',
|
|
PORT: '3000',
|
|
SHD_BUILD_NUMBER: release,
|
|
SHD_COMMIT_SHA: revision,
|
|
};
|
|
if (scope === 'scheduler') {
|
|
return {
|
|
...common,
|
|
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
|
|
};
|
|
}
|
|
if (targetEnvironment === 'staging') {
|
|
return {
|
|
...common,
|
|
APP_ORIGIN: 'https://stage.shd.build',
|
|
DISCORD_REDIRECT_URI: 'https://stage.shd.build/auth/discord/callback',
|
|
ORIGIN: 'https://stage.shd.build',
|
|
PASSKEY_RP_ID: 'shd.build',
|
|
SKIP_DEFAULT_USERS: 'true',
|
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
|
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
|
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
|
UBISOFT_NEWS_IMPORT_ENABLED: 'false',
|
|
};
|
|
}
|
|
return {
|
|
...common,
|
|
APP_ORIGIN: 'https://shd.build',
|
|
DISCORD_REDIRECT_URI: 'https://shd.build/auth/discord/callback',
|
|
ORIGIN: 'https://shd.build',
|
|
PASSKEY_RP_ID: 'shd.build',
|
|
SKIP_DEFAULT_USERS: 'true',
|
|
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
|
|
TDR_DATABASE_PATH: '/app/data/shd-build-production.db',
|
|
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
|
|
UBISOFT_NEWS_IMPORT_ENABLED: 'true',
|
|
};
|
|
}
|
|
|
|
function runAsNode(command, environmentVariables) {
|
|
const usesPrivilegeDrop =
|
|
typeof process.getuid === 'function' && process.getuid() === 0;
|
|
const executable = usesPrivilegeDrop ? 'gosu' : command[0];
|
|
const args = usesPrivilegeDrop
|
|
? ['shd-runtime', ...command]
|
|
: command.slice(1);
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(executable, args, {
|
|
env: environmentVariables,
|
|
stdio: 'inherit',
|
|
});
|
|
const signalHandlers = new Map();
|
|
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
|
|
const handler = () => child.kill(signal);
|
|
signalHandlers.set(signal, handler);
|
|
process.once(signal, handler);
|
|
}
|
|
const cleanup = () => {
|
|
for (const [signal, handler] of signalHandlers) {
|
|
process.off(signal, handler);
|
|
}
|
|
};
|
|
child.once('error', (error) => {
|
|
cleanup();
|
|
reject(error);
|
|
});
|
|
child.once('exit', (code, signal) => {
|
|
cleanup();
|
|
if (signal) {
|
|
process.kill(process.pid, signal);
|
|
} else if (code === 0) {
|
|
resolve();
|
|
} else {
|
|
reject(new Error(`Runtime command exited with status ${code ?? 1}.`));
|
|
}
|
|
});
|
|
});
|
|
}
|