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
305 lines
9.6 KiB
TypeScript
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`;
|
|
}
|
|
}
|