UNPKG

@13w/miri

Version:

MongoDB patch manager

365 lines 14.8 kB
import { homedir } from 'node:os'; import { lstat, readFile, realpath } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { join } from 'node:path'; Error.stackTraceLimit = Infinity; import colors from 'colors'; import { Command } from 'commander'; import { Table } from 'console-table-printer'; import SSHConfig from 'ssh-config'; import { createTunnel } from 'tunnel-ssh'; import Miri, { IndexStatus, PatchStatus } from './miri.js'; import connection from './mongodb.js'; const pkg = await readFile(join(import.meta.dirname, '../package.json'), 'utf-8').then((content) => JSON.parse(content)); const mirirc = await readFile(join(process.cwd(), '.mirirc'), 'utf-8').then((content) => JSON.parse(content), () => ({})); const { SSH_AUTH_SOCK } = process.env; const fileExists = (filename) => lstat(filename).then(() => true, () => false); const isKeyEncrypted = (key) => key.toString('utf-8') .split('\n') .slice(1, -1) .join('') .includes('bcrypt'); const isKeyPublic = (key) => /^(ssh-(rsa|ed25519)|ecdsa-sha2-nistp\d+)\s+[A-Za-z0-9+/=]+/.test(key.toString().trim()); const listSshAgentKeys = async () => { const keys = []; if (!SSH_AUTH_SOCK || !(await lstat(SSH_AUTH_SOCK)).isSocket()) { return keys; } const conn = createConnection(SSH_AUTH_SOCK, () => { const request = Buffer.from([0, 0, 0, 1, 0x0B]); // SSH_AGENTC_REQUEST_IDENTITIES conn.end(request); }); console.log(colors.gray(' reading keys from SSH-Agent...')); for await (const chunk of conn) { if (chunk.length < 5 || chunk.readUInt8(4) !== 12) { continue; } const numKeys = chunk.readUInt32BE(5); let offset = 9; for (let i = 0; i < numKeys; i += 1) { const keyLen = chunk.readUInt32BE(offset); offset += 4; const key = chunk.slice(offset, offset + keyLen).toString("base64"); offset += keyLen; offset += chunk.readUInt32BE(offset) + 4; keys.push(key); } } if (keys.length) { console.log(colors.gray(` ${keys.length} loaded...`)); } return keys; }; const askPassword = async (message = 'Password: ') => { process.stdin.resume(); process.stdin.setEncoding('utf8'); process.stdin.setRawMode(true); return new Promise((resolve, reject) => { let password = ''; const readPass = function (chunk) { const str = chunk.toString(); // backspace if (str.charCodeAt(0) === 127) { password = password.slice(0, -1); return; } switch (str) { case '\n': case '\r': case '\u0004': // They've finished typing their password process.stdin.setRawMode(false); process.stdin.pause(); process.stdout.write('\n'); process.stdin.off('data', readPass); resolve(password); return; case '\u0003': // Ctrl C process.stdin.off('data', readPass); reject(new Error('Cancelled')); return; default: // More password characters password += str; break; } }; process.stdout.write(message); process.stdin.on('data', readPass); }); }; const program = new Command(); program.version(pkg.version); program.option('-e --env <environment>', 'Environment name from .mirirc', 'default'); program.option('-m --migrations <folder>', `Folder with migrations (default: "${join(process.cwd(), 'migrations')}")`); program.option('-d --db <mongo-uri>', 'MongoDB Connection URI (default: "mongodb://localhost:27017/test")'); program.option('--no-direct-connection', 'Disable direct connection', true); program.option('--ssh-profile <profile>', 'Connect via SSH using profile'); program.option('--ssh-host <host>', 'Connect via SSH proxy Host'); program.option('--ssh-port <port>', 'Connect via SSH proxy Port'); program.option('--ssh-user <user>', 'Connect via SSH proxy User'); program.option('--ssh-key <path/to/key>', 'Connect via SSH proxy IdentityKey'); let configCache; const getConfig = async (programOpts) => { if (configCache) { return configCache; } const envs = mirirc.environments ?? { default: mirirc }; const env = envs[programOpts.env ?? 'default'] ?? {}; const config = Object.assign({}, mirirc, env, programOpts); if (config.sshProfile) { const sshConfigPath = join(homedir() ?? '', '.ssh/config'); const sshConfigContent = await readFile(sshConfigPath, 'utf-8'); console.log(`Reading profile ${config.sshProfile} from SSH config ${sshConfigPath}`); const parsed = SSHConfig.parse(sshConfigContent).compute(config.sshProfile); config.sshHost = programOpts.sshHost ?? parsed.Hostname; config.sshPort = programOpts.sshPort ?? parsed.Port; config.sshKey = programOpts.sshKey ?? parsed.IdentityFile?.[0]; config.sshUser = programOpts.sshUser ?? parsed.User; config.forceSshAgent = Boolean(programOpts.sshAgent); } if (config.sshKey) { if (config.sshKey.startsWith('~/')) { config.sshKey = config.sshKey.replace(/^~/, homedir()); } config.sshKey = await realpath(config.sshKey); if (config.sshKey.endsWith('.pub')) { if (await fileExists(config.sshKey)) { config.sshPublicKey = config.sshKey; } const sshKey = config.sshKey.slice(0, -4); delete config.sshKey; if (await fileExists(sshKey)) { config.sshKey = sshKey; } } else if (await fileExists(`${config.sshKey}.pub`)) { config.sshPublicKey = `${config.sshKey}.pub`; } } return configCache = config; }; const createSSHTunnel = async (opts) => { const config = await getConfig(opts); const sshOptions = { host: config.sshHost, port: Number(config.sshPort ?? 22), username: config.sshUser, }; sshOptions.agent = SSH_AUTH_SOCK; if (config.sshPublicKey) { const agentKeys = await listSshAgentKeys(); const publicKeyBody = await readFile(config.sshPublicKey, 'utf-8').catch(() => ''); if (agentKeys.includes(publicKeyBody.split(' ')[1])) { console.log(colors.gray(' Using SSH-Agent')); // key already loaded } else if (config.sshKey) { const privateKey = await readFile(config.sshKey).catch(() => void 0); if (privateKey && !isKeyPublic(privateKey)) { sshOptions.privateKey = privateKey; console.log(colors.gray(` Using private key: ${config.sshKey}`)); if (isKeyEncrypted(privateKey)) { sshOptions.passphrase = await askPassword(); } } } } // console.dir([config, sshOptions]) const dst = new URL(config.db); const forwardOptions = { dstPort: Number(dst.port ?? 27017), dstAddr: dst.hostname, }; const [server] = await createTunnel({ autoClose: true, reconnectOnError: true }, {}, sshOptions, forwardOptions); const addressInfo = server.address(); dst.host = addressInfo.family === 'IPv6' ? `[${String(addressInfo.address)}]` : String(addressInfo.address); dst.port = String(addressInfo.port); return dst.toString(); }; const getMiri = async () => { const config = await getConfig(program.opts()); const db = config.sshHost ? await createSSHTunnel(config) : config.db; const dbUri = new URL(db); dbUri.searchParams.append('directConnection', String(config.directConnection ?? true)); dbUri.searchParams.append('appName', `miri+v${pkg.version}`); const client = await connection(dbUri.toString()); return new Miri(client, { localMigrations: config.migrations, }); }; const status = async (remote = false, group, all = false) => { const miri = await getMiri(); const patches = await miri.stat(remote, group); if (!group && all) { const initPatches = await miri.stat(remote, 'init'); patches.unshift(...initPatches); } await miri[Symbol.asyncDispose](); if (!patches.length) { return; } const table = new Table({}); for (const patch of patches) { const printable = { group: patch.group, name: patch.name, status: PatchStatus[patch.status], degradation: patch.degradation, }; table.addRow(printable, { color: { [PatchStatus.Ok]: 'white', [PatchStatus.New]: 'cyan', [PatchStatus.Updated]: 'white_bold', [PatchStatus.Changed]: 'yellow', [PatchStatus.Degraded]: 'blue', [PatchStatus.Removed]: 'red', }[patch.status], }); } table.printTable(); }; const syncIndexes = async (miri, coll) => { let group = ''; console.group('Starting synchronization...'); for await (const { collection, status, name, error } of miri.indexesSync(coll)) { if (group !== collection) { if (group) { console.log('Done'); console.groupEnd(); } group = collection; console.group(`Collection: ${collection}...`); } if (status === IndexStatus.Applied) { continue; } console[error ? 'group' : 'log'](`${status === IndexStatus.New ? 'Creating' : 'Removing'} index ${name}... ${error ? 'failed' : 'done'}`); if (error) { console.log(colors.red(error.message)); console.groupEnd(); } } console.groupEnd(); }; program.command('status') .option('--all', 'show all applied patches') .description('Displays list of applied migrations') .action(({ all }) => status(true, void 0, all)); program.command('sync') .description('Applies all available patches from migrations folder') .option('--degraded', 'Re-apply patches on degraded migrations') .option('--all', 'Re-apply all patches') .action(async (opts) => { const miri = await getMiri(); await miri.init({ force: opts.all }); await syncIndexes(miri); await miri.sync(opts); await miri[Symbol.asyncDispose](); }); const initProgram = program.command('init') .description('Manage initial scripts'); initProgram.command('apply') .argument('[patch]', 'patch name') .option('--no-exec', 'Don\' execute patch, just set as done') .option('--force', 'Force apply patch') .action(async (patch, opts) => { const miri = await getMiri(); await miri.init(opts, patch); await miri[Symbol.asyncDispose](); }); initProgram.command('remove') .argument('<patch>', 'patch name') .option('--no-exec', 'Don\' execute patch, just set as done') .action(async (patch, opts) => { const miri = await getMiri(); await miri.remove('init', patch, Boolean(opts?.exec)); await miri[Symbol.asyncDispose](); }); initProgram.command('status') .action(() => status(false, 'init')); const indexesProgram = program.command('indexes') .description('Manage indexes'); indexesProgram.command('status') .argument('[collection]', 'MongoDB Collection name') .option('-q --quiet', 'Show only changes', false) .action(async (collection, { quiet }) => { const miri = await getMiri(); const structure = await miri.indexesDiff(collection); await miri[Symbol.asyncDispose](); for (const [collection, indexes] of Object.entries(structure)) { let changes = false; for (const [name, detail] of Object.entries(indexes)) { if (quiet && detail.status === IndexStatus.Applied) { continue; } const color = { [IndexStatus.New]: colors.green, [IndexStatus.Updated]: colors.green, [IndexStatus.Applied]: colors.white, [IndexStatus.Removed]: colors.red, }[detail.status]; const point = { [IndexStatus.New]: colors.green.bold('+ '), [IndexStatus.Updated]: colors.green.bold('~ '), [IndexStatus.Applied]: colors.cyan('\u00b7 '), [IndexStatus.Removed]: colors.red.bold('- '), }[detail.status]; if (!changes) { console.group(colors.bold(`Collection ${collection}:`)); changes = true; } console.log(color(`${point}${name}`)); } console.groupEnd(); } }); const indexSyncProgram = indexesProgram.command('sync'); indexSyncProgram.argument('[collection]', 'MongoDB Collection name', '') .action(async (collection) => { const miri = await getMiri(); await syncIndexes(miri, collection); await miri[Symbol.asyncDispose](); }); const patchProgram = program.command('patch') .description('Applies patch to database'); patchProgram.command('diff') .description('Displays difference between local and applied migrations') .action(() => status()); patchProgram.command('sync') .description('Applies migrations') .option('--remote', 'Remote only') .option('--degraded', 'Re-apply patches on degraded migrations') .option('--all', 'Re-apply all patches') .action(async (opts) => { const miri = await getMiri(); await miri.sync(opts); await miri[Symbol.asyncDispose](); }); patchProgram.command('apply') .argument('<group>', 'group name') .argument('<patch>', 'patch name') .option('--no-exec', 'Don\' execute patch, just set as done') .action(async (group, patch, opts) => { const miri = await getMiri(); await miri.applySingle(group, patch, Boolean(opts.exec)); await miri[Symbol.asyncDispose](); }); patchProgram.command('remove') .argument('<group>', 'group name') .argument('<patch>', 'patch name') .option('--no-exec', 'Don\' execute patch, just set as done') .action(async (group, patch, opts) => { const miri = await getMiri(); await miri.remove(group, patch, Boolean(opts.exec)); await miri[Symbol.asyncDispose](); }); program.parse(); for (const eventName of ['unhandledRejection', 'rejectionHandled', 'uncaughtException']) { process.on(eventName, (error) => { console.error(colors.red(`${eventName}:\n ${error.name}: ${error.message}`)); }); } //# sourceMappingURL=cli.js.map