/** * @ai-summary Service for restoring backups * @ai-context Handles preview and execution of backup restoration with safety backup */ import { exec } from 'child_process'; import { promisify } from 'util'; import * as fsp from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; import { Pool } from 'pg'; import { logger } from '../../../core/logging/logger'; import { appConfig } from '../../../core/config/config-loader'; import { BackupRepository } from '../data/backup.repository'; import { BackupArchiveService } from './backup-archive.service'; import { BackupService } from './backup.service'; import { BACKUP_CONFIG, BackupManifest, RestoreOptions, RestoreResult, RestorePreviewResponse, } from './backup.types'; const execAsync = promisify(exec); export class BackupRestoreService { private repository: BackupRepository; private archiveService: BackupArchiveService; private backupService: BackupService; private restoreInProgress: boolean = false; constructor(pool: Pool) { this.repository = new BackupRepository(pool); this.archiveService = new BackupArchiveService(); this.backupService = new BackupService(pool); } /** * Previews what will be restored (dry run) */ async previewRestore(backupId: string): Promise { const backup = await this.repository.getBackupById(backupId); if (!backup) { throw new Error('Backup not found'); } if (backup.status !== 'completed') { throw new Error('Cannot restore from incomplete backup'); } const filePath = (backup.metadata as any)?.archivePath || backup.filePath; if (!filePath) { throw new Error('Backup file path not found'); } // Validate archive and get manifest const validation = await this.archiveService.validateArchive(filePath); if (!validation.valid) { throw new Error(`Invalid backup archive: ${validation.error}`); } const manifest = validation.manifest!; const warnings: string[] = []; // Check for potential issues if (manifest.version !== BACKUP_CONFIG.archiveVersion) { warnings.push(`Backup version (${manifest.version}) differs from current (${BACKUP_CONFIG.archiveVersion})`); } // Estimate duration based on file sizes const totalBytes = backup.fileSizeBytes; const estimatedSeconds = Math.max(30, Math.ceil(totalBytes / (10 * 1024 * 1024))); // ~10MB/s const estimatedDuration = this.formatDuration(estimatedSeconds); return { backupId, manifest, warnings, estimatedDuration, }; } /** * Executes a backup restoration */ async executeRestore(options: RestoreOptions): Promise { if (this.restoreInProgress) { throw new Error('A restore operation is already in progress'); } this.restoreInProgress = true; let safetyBackupId: string | undefined; try { const backup = await this.repository.getBackupById(options.backupId); if (!backup) { throw new Error('Backup not found'); } if (backup.status !== 'completed') { throw new Error('Cannot restore from incomplete backup'); } const filePath = (backup.metadata as any)?.archivePath || backup.filePath; if (!filePath) { throw new Error('Backup file path not found'); } logger.info('Starting restore operation', { backupId: options.backupId, createSafetyBackup: options.createSafetyBackup, }); // Create safety backup before restore if (options.createSafetyBackup !== false) { logger.info('Creating safety backup before restore'); const safetyResult = await this.backupService.createBackup({ name: `Pre-restore safety backup`, backupType: 'manual', includeDocuments: true, }); if (!safetyResult.success) { throw new Error(`Failed to create safety backup: ${safetyResult.error}`); } safetyBackupId = safetyResult.backupId; logger.info('Safety backup created', { safetyBackupId }); } // Extract archive to temp directory const workDir = path.join(BACKUP_CONFIG.tempPath, `restore-${options.backupId}`); await fsp.mkdir(workDir, { recursive: true }); try { logger.info('Extracting backup archive'); const manifest = await this.archiveService.extractArchive(filePath, workDir); // Restore database logger.info('Restoring database'); await this.restoreDatabase(workDir, manifest); // Restore documents logger.info('Restoring documents'); await this.restoreDocuments(workDir, manifest); logger.info('Restore completed successfully', { backupId: options.backupId, safetyBackupId, }); return { success: true, safetyBackupId, }; } finally { // Cleanup work directory await fsp.rm(workDir, { recursive: true, force: true }).catch(() => {}); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); logger.error('Restore failed', { backupId: options.backupId, error: errorMessage, safetyBackupId, }); return { success: false, safetyBackupId, error: errorMessage, }; } finally { this.restoreInProgress = false; } } /** * Gets the current restore status */ getRestoreStatus(): { inProgress: boolean } { return { inProgress: this.restoreInProgress }; } /** * Restores the database from a backup */ private async restoreDatabase(workDir: string, manifest: BackupManifest): Promise { const dbFilename = manifest.contents.database.filename; const sqlPath = path.join(workDir, 'database', dbFilename); // Verify the SQL file exists await fsp.access(sqlPath); // Verify checksum const sqlContent = await fsp.readFile(sqlPath); const actualChecksum = `sha256:${crypto.createHash('sha256').update(sqlContent).digest('hex')}`; if (actualChecksum !== manifest.contents.database.checksum) { throw new Error('Database checksum mismatch - backup may be corrupted'); } // Database connection details from app config const dbConfig = appConfig.config.database; const dbPassword = appConfig.secrets.postgres_password; const dbHost = dbConfig.host; const dbPort = dbConfig.port.toString(); const dbUser = dbConfig.user; const dbName = dbConfig.name; // Set PGPASSWORD environment variable for psql const pgEnv = { ...process.env, PGPASSWORD: dbPassword }; try { // Note: We no longer terminate connections before restore. // The --clean flag in pg_dump generates DROP statements that handle existing data. // Terminating connections would kill the backend's own pool and break the HTTP response. // Restore the database using psql await execAsync( `psql -h ${dbHost} -p ${dbPort} -U ${dbUser} -d ${dbName} -f "${sqlPath}"`, { env: pgEnv } ); logger.info('Database restored successfully', { tablesCount: manifest.contents.database.tablesCount, }); } catch (error) { throw new Error(`Database restoration failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Restores documents from a backup */ private async restoreDocuments(workDir: string, manifest: BackupManifest): Promise { const docsBackupPath = path.join(workDir, 'documents'); const docsTargetPath = BACKUP_CONFIG.documentsPath; // Check if documents were included in backup const docsExist = await fsp.access(docsBackupPath).then(() => true).catch(() => false); if (!docsExist || manifest.contents.documents.totalFiles === 0) { logger.info('No documents to restore'); return; } try { // Ensure target directory exists await fsp.mkdir(docsTargetPath, { recursive: true }); // Clear existing documents (we're doing a full restore) const existingEntries = await fsp.readdir(docsTargetPath); for (const entry of existingEntries) { await fsp.rm(path.join(docsTargetPath, entry), { recursive: true, force: true }); } // Copy documents from backup await this.copyDirRecursive(docsBackupPath, docsTargetPath); logger.info('Documents restored successfully', { totalFiles: manifest.contents.documents.totalFiles, usersCount: manifest.contents.documents.usersCount, }); } catch (error) { throw new Error(`Documents restoration failed: ${error instanceof Error ? error.message : String(error)}`); } } /** * Recursively copies a directory */ private async copyDirRecursive(src: string, dest: string): Promise { const entries = await fsp.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await fsp.mkdir(destPath, { recursive: true }); await this.copyDirRecursive(srcPath, destPath); } else { await fsp.copyFile(srcPath, destPath); } } } /** * Formats duration in human-readable format */ private formatDuration(seconds: number): string { if (seconds < 60) { return `${seconds} seconds`; } const minutes = Math.floor(seconds / 60); const remainingSeconds = seconds % 60; if (remainingSeconds === 0) { return `${minutes} minute${minutes > 1 ? 's' : ''}`; } return `${minutes} minute${minutes > 1 ? 's' : ''} ${remainingSeconds} seconds`; } }