145 lines
4.5 KiB
TypeScript
145 lines
4.5 KiB
TypeScript
import cron from 'node-cron';
|
|
import path from 'path';
|
|
import fs from 'fs/promises';
|
|
import { spawn } from 'child_process';
|
|
import ELKService from './elkService';
|
|
|
|
export class DbBackupService {
|
|
private static instance: DbBackupService;
|
|
private cronJob: cron.ScheduledTask | null = null;
|
|
private isInitialized = false;
|
|
private isRunning = false;
|
|
|
|
private constructor() {}
|
|
|
|
public static getInstance(): DbBackupService {
|
|
if (!DbBackupService.instance) {
|
|
DbBackupService.instance = new DbBackupService();
|
|
}
|
|
return DbBackupService.instance;
|
|
}
|
|
|
|
public async initialize(): Promise<void> {
|
|
if (this.isInitialized) {
|
|
ELKService.info('[DbBackupService] Already initialized');
|
|
return;
|
|
}
|
|
|
|
const enabled = (process.env.DB_BACKUP_ENABLED || 'true').toLowerCase() === 'true';
|
|
if (!enabled) {
|
|
this.isInitialized = true;
|
|
ELKService.info('[DbBackupService] Disabled by DB_BACKUP_ENABLED');
|
|
return;
|
|
}
|
|
|
|
const schedule = process.env.DB_BACKUP_CRON || '0 2 * * *';
|
|
const timezone = process.env.DB_BACKUP_TIMEZONE || 'UTC';
|
|
|
|
this.cronJob = cron.schedule(schedule, async () => {
|
|
await this.runBackup();
|
|
}, { scheduled: false, timezone });
|
|
|
|
this.cronJob.start();
|
|
this.isInitialized = true;
|
|
|
|
ELKService.info('[DbBackupService] Initialized');
|
|
ELKService.logBusinessEvent('db_backup_service_initialized', { schedule, timezone });
|
|
}
|
|
|
|
private getBackupDir(): string {
|
|
return process.env.DB_BACKUP_DIR || path.resolve(process.cwd(), 'backups');
|
|
}
|
|
|
|
private getRetentionDays(): number {
|
|
const v = parseInt(process.env.DB_BACKUP_RETENTION_DAYS || '7', 10);
|
|
return Number.isFinite(v) && v > 0 ? v : 7;
|
|
}
|
|
|
|
private async runBackup(): Promise<void> {
|
|
if (this.isRunning) {
|
|
ELKService.warn('[DbBackupService] Backup already running, skipping');
|
|
return;
|
|
}
|
|
this.isRunning = true;
|
|
|
|
const startedAt = Date.now();
|
|
try {
|
|
const uri = process.env.MONGODB_URI;
|
|
if (!uri) {
|
|
ELKService.error('[DbBackupService] Missing MONGODB_URI, cannot run backup', new Error('MONGODB_URI is not set'));
|
|
return;
|
|
}
|
|
|
|
const backupDir = this.getBackupDir();
|
|
await fs.mkdir(backupDir, { recursive: true });
|
|
|
|
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
const backupFile = path.join(backupDir, `mongo-backup-${timestamp}.archive.gz`);
|
|
|
|
await this.runMongodump(uri, backupFile);
|
|
const { deletedCount } = await this.applyRetention(backupDir, this.getRetentionDays());
|
|
|
|
ELKService.info('[DbBackupService] Backup completed', {
|
|
backupFile: path.basename(backupFile),
|
|
retentionDays: this.getRetentionDays(),
|
|
deletedCount,
|
|
durationMs: Date.now() - startedAt
|
|
});
|
|
ELKService.logBusinessEvent('db_backup_completed', {
|
|
backupFile: path.basename(backupFile),
|
|
retentionDays: this.getRetentionDays(),
|
|
deletedCount
|
|
});
|
|
} catch (error) {
|
|
ELKService.error('[DbBackupService] Backup failed', error as Error);
|
|
ELKService.logBusinessEvent('db_backup_failed', { message: (error as Error)?.message || 'Unknown error' });
|
|
} finally {
|
|
this.isRunning = false;
|
|
}
|
|
}
|
|
|
|
private async runMongodump(mongoUri: string, archivePath: string): Promise<void> {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const child = spawn('mongodump', [`--uri=${mongoUri}`, `--archive=${archivePath}`, '--gzip'], {
|
|
stdio: ['ignore', 'ignore', 'pipe']
|
|
});
|
|
|
|
let stderr = '';
|
|
child.stderr.on('data', chunk => {
|
|
stderr += chunk?.toString?.() || '';
|
|
if (stderr.length > 20000) stderr = stderr.slice(-20000);
|
|
});
|
|
|
|
child.on('error', err => reject(err));
|
|
child.on('close', code => {
|
|
if (code === 0) return resolve();
|
|
reject(new Error(`mongodump exited with code ${code}${stderr ? `: ${stderr}` : ''}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
private async applyRetention(backupDir: string, retentionDays: number): Promise<{ deletedCount: number }> {
|
|
const now = Date.now();
|
|
const maxAgeMs = retentionDays * 24 * 60 * 60 * 1000;
|
|
const files = await fs.readdir(backupDir);
|
|
let deletedCount = 0;
|
|
|
|
for (const f of files) {
|
|
if (!f.startsWith('mongo-backup-') || !f.endsWith('.archive.gz')) continue;
|
|
const full = path.join(backupDir, f);
|
|
try {
|
|
const st = await fs.stat(full);
|
|
const ageMs = now - st.mtimeMs;
|
|
if (ageMs > maxAgeMs) {
|
|
await fs.unlink(full);
|
|
deletedCount += 1;
|
|
}
|
|
} catch {
|
|
}
|
|
}
|
|
|
|
return { deletedCount };
|
|
}
|
|
}
|
|
|