@13w/miri
Version:
MongoDB patch manager
365 lines • 14.8 kB
JavaScript
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