Files
motovaultpro/backend/src/features/backup/domain/backup-restore.service.ts
Eric Gullickson 9043a581b1
All checks were successful
Deploy to Staging / Build Images (push) Successful in 4m31s
Deploy to Staging / Deploy to Staging (push) Successful in 37s
Deploy to Staging / Verify Staging (push) Successful in 6s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
feat: backup improvements
2026-01-01 13:57:36 -06:00

305 lines
9.6 KiB
TypeScript

/**
* @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<RestorePreviewResponse> {
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<RestoreResult> {
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<void> {
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<void> {
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<void> {
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`;
}
}