feat: migrate secrets to Infisical
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
#!/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',
|
||||
'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}.`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user