hayai-db
Version:
ā” Instantly create and manage local databases with one command
368 lines (367 loc) ⢠16 kB
JavaScript
import { Command } from 'commander';
import chalk from 'chalk';
import ora from 'ora';
import * as path from 'path';
import * as fs from 'fs/promises';
import { spawn } from 'child_process';
import { getDockerManager } from '../../core/docker.js';
import { getTemplate } from '../../core/templates.js';
async function createSnapshotDirectory(dir) {
try {
await fs.access(dir);
}
catch {
await fs.mkdir(dir, { recursive: true });
}
}
async function createSnapshot(instance, snapshotPath) {
const template = getTemplate(instance.engine);
if (!template) {
throw new Error(`Template not found for engine: ${instance.engine}`);
}
// Choose appropriate backup method based on database type
switch (instance.engine) {
case 'postgresql':
case 'timescaledb':
await createPostgreSQLSnapshot(instance.name, snapshotPath);
break;
case 'mariadb':
await createMariaDBSnapshot(instance.name, snapshotPath);
break;
case 'redis':
await createRedisSnapshot(instance.name, snapshotPath);
break;
case 'influxdb2':
case 'influxdb3':
await createInfluxDBSnapshot(instance.name, snapshotPath);
break;
case 'cassandra':
await createCassandraSnapshot(instance.name, snapshotPath);
break;
default:
await createGenericSnapshot(instance.name, snapshotPath);
}
}
async function createPostgreSQLSnapshot(instanceName, snapshotPath) {
return new Promise((resolve, reject) => {
const dumpProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'pg_dump', '-U', 'postgres', '--clean', '--create'
], { stdio: ['inherit', 'pipe', 'pipe'] });
const writeStream = require('fs').createWriteStream(snapshotPath);
dumpProcess.stdout.pipe(writeStream);
dumpProcess.on('close', (code) => {
writeStream.end();
code === 0 ? resolve() : reject(new Error('PostgreSQL snapshot failed'));
});
dumpProcess.on('error', reject);
});
}
async function createMariaDBSnapshot(instanceName, snapshotPath) {
return new Promise((resolve, reject) => {
const dumpProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'mysqldump', '-u', 'root', '--all-databases'
], { stdio: ['inherit', 'pipe', 'pipe'] });
const writeStream = require('fs').createWriteStream(snapshotPath);
dumpProcess.stdout.pipe(writeStream);
dumpProcess.on('close', (code) => {
writeStream.end();
code === 0 ? resolve() : reject(new Error('MariaDB snapshot failed'));
});
dumpProcess.on('error', reject);
});
}
async function createRedisSnapshot(instanceName, snapshotPath) {
return new Promise((resolve, reject) => {
// Create RDB backup
const bgsaveProcess = spawn('docker', [
'exec', `${instanceName}-db`, 'redis-cli', 'BGSAVE'
]);
bgsaveProcess.on('close', async (code) => {
if (code !== 0) {
reject(new Error('Redis background save failed'));
return;
}
// Wait for backup to complete
await new Promise(resolve => setTimeout(resolve, 2000));
// Copy RDB file
const copyProcess = spawn('docker', [
'cp', `${instanceName}-db:/data/dump.rdb`, snapshotPath
]);
copyProcess.on('close', (copyCode) => {
copyCode === 0 ? resolve() : reject(new Error('Failed to copy Redis snapshot'));
});
copyProcess.on('error', reject);
});
bgsaveProcess.on('error', reject);
});
}
async function createInfluxDBSnapshot(instanceName, snapshotPath) {
return new Promise((resolve, reject) => {
const backupProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'influx', 'backup', '/tmp/backup'
]);
backupProcess.on('close', async (code) => {
if (code !== 0) {
reject(new Error('InfluxDB backup failed'));
return;
}
// Create tar archive
const tarProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'tar', '-czf', '/tmp/influx-backup.tar.gz', '/tmp/backup'
]);
tarProcess.on('close', (tarCode) => {
if (tarCode !== 0) {
reject(new Error('Failed to create backup archive'));
return;
}
// Copy to host
const copyProcess = spawn('docker', [
'cp', `${instanceName}-db:/tmp/influx-backup.tar.gz`, snapshotPath
]);
copyProcess.on('close', (copyCode) => {
copyCode === 0 ? resolve() : reject(new Error('Failed to copy InfluxDB snapshot'));
});
copyProcess.on('error', reject);
});
tarProcess.on('error', reject);
});
backupProcess.on('error', reject);
});
}
async function createGenericSnapshot(instanceName, snapshotPath) {
return new Promise((resolve, reject) => {
const backupProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'tar', '-czf', '/tmp/backup.tar.gz', '/data'
]);
backupProcess.on('close', (code) => {
if (code !== 0) {
reject(new Error('Generic backup failed'));
return;
}
const copyProcess = spawn('docker', [
'cp', `${instanceName}-db:/tmp/backup.tar.gz`, snapshotPath
]);
copyProcess.on('close', (copyCode) => {
copyCode === 0 ? resolve() : reject(new Error('Failed to copy generic snapshot'));
});
copyProcess.on('error', reject);
});
backupProcess.on('error', reject);
});
}
async function createCassandraSnapshot(instanceName, snapshotPath) {
return new Promise((resolve, reject) => {
const snapshotProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'nodetool', 'snapshot'
]);
snapshotProcess.on('close', (code) => {
if (code !== 0) {
reject(new Error('Cassandra snapshot failed'));
return;
}
const copyProcess = spawn('docker', [
'exec', `${instanceName}-db`,
'tar', '-czf', '/tmp/cassandra-snapshot.tar.gz', '/var/lib/cassandra/data'
]);
copyProcess.on('close', (tarCode) => {
if (tarCode !== 0) {
reject(new Error('Failed to create Cassandra archive'));
return;
}
const finalCopyProcess = spawn('docker', [
'cp', `${instanceName}-db:/tmp/cassandra-snapshot.tar.gz`, snapshotPath
]);
finalCopyProcess.on('close', (copyCode) => {
copyCode === 0 ? resolve() : reject(new Error('Failed to copy Cassandra snapshot'));
});
finalCopyProcess.on('error', reject);
});
copyProcess.on('error', reject);
});
snapshotProcess.on('error', reject);
});
}
export const snapshotCommand = new Command('snapshot')
.description('Create snapshots of database instances')
.argument('<name>', 'Database instance name')
.option('-o, --output <path>', 'Output directory for snapshots', './snapshots')
.option('-c, --compress', 'Compress the snapshot')
.option('--format <format>', 'Snapshot format (sql, rdb, tar)', 'sql')
.action(async (name, options) => {
try {
const dockerManager = getDockerManager();
await dockerManager.initialize();
const instance = dockerManager.getInstance(name);
if (!instance) {
console.error(chalk.red(`ā Database instance '${name}' not found`));
console.log(chalk.yellow('š” Run `hayai list` to see available databases'));
process.exit(1);
}
if (instance.status !== 'running') {
console.error(chalk.red(`ā Database '${name}' must be running to create snapshot`));
console.log(chalk.yellow(`š” Start it with: ${chalk.cyan(`hayai start ${name}`)}`));
process.exit(1);
}
// Create snapshots directory
await createSnapshotDirectory(options.output || './snapshots');
// Generate snapshot filename with timestamp
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const snapshotName = `${name}-snapshot-${timestamp}`;
const outputDir = options.output || path.join(process.cwd(), 'snapshots');
// Determine file extension based on database type and format
let extension = 'sql';
if (instance.engine === 'redis')
extension = 'rdb';
if (instance.engine.includes('influx') || instance.engine === 'cassandra')
extension = 'tar.gz';
if (options.format === 'tar')
extension = 'tar.gz';
const snapshotPath = path.join(outputDir, `${snapshotName}.${extension}`);
console.log(chalk.cyan(`šø Creating snapshot of '${name}'...`));
console.log(chalk.gray(`Engine: ${instance.engine}`));
console.log(chalk.gray(`Output: ${snapshotPath}`));
const spinner = ora('Creating snapshot...').start();
await createSnapshot(instance, snapshotPath);
// Get file size
const stats = await fs.stat(snapshotPath);
const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2);
spinner.succeed(`Snapshot created successfully (${fileSizeMB} MB)`);
console.log(chalk.green('\nā
Snapshot completed!'));
console.log(chalk.yellow('š” Commands:'));
console.log(` ⢠${chalk.cyan('hayai snapshot list')} - View all snapshots`);
console.log(` ⢠${chalk.cyan('hayai snapshot restore')} - Restore from snapshot`);
}
catch (error) {
console.error(chalk.red('ā Snapshot failed:'), error instanceof Error ? error.message : error);
process.exit(1);
}
});
// Add subcommand for listing snapshots
snapshotCommand
.command('list')
.description('List all available snapshots')
.option('-d, --directory <path>', 'Snapshots directory', './snapshots')
.action(async (options) => {
try {
const snapshotsDir = path.resolve(options.directory);
try {
await fs.access(snapshotsDir);
}
catch {
console.log(chalk.yellow(`š No snapshots directory found at: ${snapshotsDir}`));
console.log(chalk.gray('Create snapshots with: hayai snapshot <database-name>'));
return;
}
const files = await fs.readdir(snapshotsDir);
const snapshotFiles = files.filter(file => file.includes('-snapshot-') &&
(file.endsWith('.sql') || file.endsWith('.rdb') || file.endsWith('.tar.gz')));
if (snapshotFiles.length === 0) {
console.log(chalk.yellow('š No snapshots found'));
console.log(chalk.gray('Create snapshots with: hayai snapshot <database-name>'));
return;
}
console.log(chalk.cyan('\nš Available Snapshots:\n'));
// Get detailed info for each snapshot
const snapshots = await Promise.all(snapshotFiles.map(async (file) => {
const filePath = path.join(snapshotsDir, file);
const stats = await fs.stat(filePath);
const sizeMB = (stats.size / (1024 * 1024)).toFixed(2);
// Parse filename to extract info
const parts = file.split('-snapshot-');
const dbName = parts[0];
const timestamp = parts[1]?.split('.')[0];
const extension = path.extname(file);
return {
file,
dbName,
timestamp: timestamp ? new Date(timestamp.replace(/-/g, ':').replace(/T/, ' ')) : new Date(),
size: sizeMB,
extension
};
}));
// Sort by timestamp (newest first)
snapshots.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime());
snapshots.forEach(snapshot => {
console.log(`šø ${chalk.bold(snapshot.file)}`);
console.log(` Database: ${chalk.cyan(snapshot.dbName)}`);
console.log(` Created: ${chalk.gray(snapshot.timestamp.toLocaleString())}`);
console.log(` Size: ${chalk.yellow(snapshot.size)} MB`);
console.log(` Format: ${chalk.magenta(snapshot.extension.substring(1))}`);
console.log('');
});
console.log(chalk.yellow('š” Commands:'));
console.log(` ⢠${chalk.cyan('hayai snapshot <name>')} - Create new snapshot`);
console.log(` ⢠${chalk.cyan('hayai snapshot restore <file>')} - Restore from snapshot`);
}
catch (error) {
console.error(chalk.red('ā Failed to list snapshots:'), error instanceof Error ? error.message : error);
}
});
// Add subcommand for removing old snapshots
snapshotCommand
.command('clean')
.description('Remove old snapshots (keeps last 5 per database)')
.option('-d, --directory <path>', 'Snapshots directory', './snapshots')
.option('-k, --keep <number>', 'Number of snapshots to keep per database', '5')
.action(async (options) => {
try {
const snapshotsDir = path.resolve(options.directory);
try {
await fs.access(snapshotsDir);
}
catch {
console.log(chalk.yellow(`š No snapshots directory found at: ${snapshotsDir}`));
return;
}
const files = await fs.readdir(snapshotsDir);
const snapshotFiles = files.filter(file => file.includes('-snapshot-') &&
(file.endsWith('.sql') || file.endsWith('.rdb') || file.endsWith('.tar.gz')));
if (snapshotFiles.length === 0) {
console.log(chalk.yellow('š No snapshots found to clean'));
return;
}
// Group by database name
const snapshotsByDb = {};
snapshotFiles.forEach(file => {
const dbName = file.split('-snapshot-')[0];
if (!snapshotsByDb[dbName]) {
snapshotsByDb[dbName] = [];
}
snapshotsByDb[dbName].push(file);
});
const keepCount = parseInt(options.keep);
let totalDeleted = 0;
for (const [dbName, snapshots] of Object.entries(snapshotsByDb)) {
// Sort by timestamp (newest first)
snapshots.sort((a, b) => {
const timestampA = a.split('-snapshot-')[1]?.split('.')[0] || '';
const timestampB = b.split('-snapshot-')[1]?.split('.')[0] || '';
return timestampB.localeCompare(timestampA);
});
const toDelete = snapshots.slice(keepCount);
if (toDelete.length > 0) {
console.log(chalk.yellow(`šļø Cleaning ${dbName}: removing ${toDelete.length} old snapshots`));
for (const file of toDelete) {
await fs.unlink(path.join(snapshotsDir, file));
console.log(chalk.gray(` Deleted: ${file}`));
totalDeleted++;
}
}
}
if (totalDeleted === 0) {
console.log(chalk.green('ā
No old snapshots to clean'));
}
else {
console.log(chalk.green(`ā
Cleaned ${totalDeleted} old snapshots`));
}
}
catch (error) {
console.error(chalk.red('ā Failed to clean snapshots:'), error instanceof Error ? error.message : error);
}
});