import { Injectable, Logger, NotFoundException, BadRequestException, Inject, forwardRef, OnModuleInit, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ConfigService } from '@nestjs/config'; import * as fs from 'fs'; import * as path from 'path'; import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity'; import { ApplicationsService } from '../applications/applications.service'; import { KubernetesService } from '../kubernetes/kubernetes.service'; import { AppRuntime, DatabaseType, ProductType } from '../common/enums'; const MAX_SNAPSHOTS = 10; @Injectable() export class SnapshotsService implements OnModuleInit { private readonly logger = new Logger(SnapshotsService.name); constructor( @InjectRepository(AppSnapshot) private snapshotsRepo: Repository, @Inject(forwardRef(() => ApplicationsService)) private applicationsService: ApplicationsService, private kubernetesService: KubernetesService, private configService: ConfigService, ) {} async onModuleInit(): Promise { try { await this.snapshotsRepo.query( `ALTER TABLE snapshots ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0`, ); } catch (e: any) { this.logger.warn(`Could not ensure snapshots.progress column: ${e.message}`); } } /** * Create a snapshot of the current state of an application. * Captures: source code zip, wp-content (for WordPress), and DB dump. */ async createSnapshot( applicationId: string, userId: string, type: SnapshotType = SnapshotType.MANUAL, label?: string, ): Promise { const app = await this.applicationsService.findOne(applicationId, userId); const snapshot = this.snapshotsRepo.create({ applicationId: app.id, createdBy: userId, type, status: SnapshotStatus.IN_PROGRESS, progress: 0, label: label || `Snapshot ${new Date().toLocaleString()}`, imageTag: app.latestImageTag || undefined, hasDatabase: app.databaseType !== DatabaseType.NONE, } as Partial); const saved = await this.snapshotsRepo.save(snapshot); // Run snapshot capture async this.captureSnapshot(saved.id, app).catch((err) => { this.logger.error(`Snapshot ${saved.id} capture failed: ${err.message}`); }); return saved; } /** * Create a pre-deploy snapshot (called automatically before each deploy). * Does NOT require userId ownership check — called internally. */ async createPreDeploySnapshot(applicationId: string, userId: string, version: string): Promise { // Use findOne without userId to bypass ownership (internal call) const app = await this.applicationsService.findOne(applicationId); const snapshot = this.snapshotsRepo.create({ applicationId: app.id, createdBy: userId, type: SnapshotType.PRE_DEPLOY, status: SnapshotStatus.IN_PROGRESS, label: `Before deploy ${version}`, imageTag: app.latestImageTag || undefined, hasDatabase: app.databaseType !== DatabaseType.NONE, } as Partial); const saved = await this.snapshotsRepo.save(snapshot); // Run snapshot capture (await it for pre-deploy to ensure it completes before deploy) await this.captureSnapshot(saved.id, app); return saved; } private async setSnapshotProgress(snapshotId: string, progress: number): Promise { try { await this.snapshotsRepo.update(snapshotId, { progress: Math.min(100, Math.max(0, progress)), }); } catch (e: any) { this.logger.debug(`Snapshot progress update skipped: ${e.message}`); } } private async captureSnapshot(snapshotId: string, app: any): Promise { const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; const snapshotDir = path.join(uploadDir, app.userId, app.id, 'snapshots', snapshotId); fs.mkdirSync(snapshotDir, { recursive: true }); const updates: Partial = {}; const managedDbOnly = app.productType === ProductType.MANAGED_DATABASE; try { await this.setSnapshotProgress(snapshotId, 5); if (!managedDbOnly) { // 1. Copy current source code zip if (app.codePath && fs.existsSync(app.codePath)) { await this.setSnapshotProgress(snapshotId, 12); const destPath = path.join(snapshotDir, 'source.zip'); fs.copyFileSync(app.codePath, destPath); updates.appArchivePath = destPath; updates.appArchiveSize = fs.statSync(destPath).size; this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`); } // 2. Archive wp-content for WordPress apps if (app.runtime === AppRuntime.WORDPRESS) { await this.setSnapshotProgress(snapshotId, 18); try { const { data, logs } = await this.kubernetesService.archiveWpContent(app); if (data && data.length > 0) { const wpPath = path.join(snapshotDir, 'wp-content.tar.gz'); fs.writeFileSync(wpPath, data); updates.wpContentArchivePath = wpPath; updates.wpContentSize = data.length; this.logger.log(`Snapshot ${snapshotId}: archived wp-content (${(data.length / 1024).toFixed(1)} KB)`); } else { this.logger.warn(`Snapshot ${snapshotId}: wp-content archive empty — ${logs}`); } } catch (e: any) { this.logger.warn(`Snapshot ${snapshotId}: wp-content archive failed — ${e.message}`); } } } // 3. Dump database if (app.databaseType !== DatabaseType.NONE) { await this.setSnapshotProgress(snapshotId, managedDbOnly ? 10 : 25); try { const mapDumpProgress = (dumpPct: number) => { const base = managedDbOnly ? 10 : 25; const end = managedDbOnly ? 95 : 90; const t = Math.min(1, Math.max(0, (dumpPct - 10) / 85)); void this.setSnapshotProgress(snapshotId, base + Math.round(t * (end - base))); }; const { data, logs } = await this.kubernetesService.exportDatabaseDump(app, mapDumpProgress); if (data && data.length > 0) { await this.setSnapshotProgress(snapshotId, 92); const dbPath = path.join(snapshotDir, 'database.sql'); fs.writeFileSync(dbPath, data); updates.dbDumpPath = dbPath; updates.dbDumpSize = data.length; this.logger.log(`Snapshot ${snapshotId}: dumped database (${(data.length / 1024).toFixed(1)} KB)`); } else { this.logger.warn(`Snapshot ${snapshotId}: DB dump empty — ${logs}`); if (managedDbOnly) { updates.status = SnapshotStatus.FAILED; updates.errorMessage = logs || 'Database dump produced no data'; updates.progress = 0; await this.snapshotsRepo.update(snapshotId, updates); await this.pruneSnapshots(app.id); return; } } } catch (e: any) { this.logger.warn(`Snapshot ${snapshotId}: DB dump failed — ${e.message}`); if (managedDbOnly) { updates.status = SnapshotStatus.FAILED; updates.errorMessage = e.message; updates.progress = 0; await this.snapshotsRepo.update(snapshotId, updates); await this.pruneSnapshots(app.id); return; } } } updates.status = SnapshotStatus.COMPLETED; updates.progress = 100; } catch (error: any) { updates.status = SnapshotStatus.FAILED; updates.errorMessage = error.message; updates.progress = 0; this.logger.error(`Snapshot ${snapshotId} failed: ${error.message}`); } await this.snapshotsRepo.update(snapshotId, updates); // Prune old snapshots (keep max 10) await this.pruneSnapshots(app.id); } /** * Keep only the latest MAX_SNAPSHOTS per application. Delete older ones + their files. */ private async pruneSnapshots(applicationId: string): Promise { const all = await this.snapshotsRepo.find({ where: { applicationId }, order: { createdAt: 'DESC' }, }); if (all.length <= MAX_SNAPSHOTS) return; const toDelete = all.slice(MAX_SNAPSHOTS); for (const snap of toDelete) { this.deleteSnapshotFiles(snap); await this.snapshotsRepo.remove(snap); } this.logger.log(`Pruned ${toDelete.length} old snapshot(s) for app ${applicationId}`); } private deleteSnapshotFiles(snap: AppSnapshot): void { for (const filePath of [snap.appArchivePath, snap.wpContentArchivePath, snap.dbDumpPath]) { if (filePath && fs.existsSync(filePath)) { try { fs.unlinkSync(filePath); } catch {} } } // Try to remove the snapshot directory if (snap.appArchivePath) { const dir = path.dirname(snap.appArchivePath); try { if (fs.existsSync(dir)) fs.rmdirSync(dir); } catch {} } } /** * List snapshots for an application (newest first). */ async listSnapshots(applicationId: string, userId: string): Promise { // Verify user has access await this.applicationsService.findOne(applicationId, userId); return this.snapshotsRepo.find({ where: { applicationId }, order: { createdAt: 'DESC' }, take: MAX_SNAPSHOTS, }); } /** * Get a single snapshot (with ownership check). */ async findOne(snapshotId: string, userId: string): Promise { const snapshot = await this.snapshotsRepo.findOne({ where: { id: snapshotId }, relations: { application: true }, }); if (!snapshot) throw new NotFoundException('Snapshot not found'); // Verify ownership await this.applicationsService.findOne(snapshot.applicationId, userId); return snapshot; } /** * Download a snapshot artifact (source, wp-content, or database). */ async getDownloadPath( snapshotId: string, userId: string, artifact: 'source' | 'wp-content' | 'database', ): Promise<{ filePath: string; fileName: string }> { const snapshot = await this.findOne(snapshotId, userId); let filePath: string | null = null; let fileName = ''; switch (artifact) { case 'source': filePath = snapshot.appArchivePath; fileName = `${snapshot.applicationId}-source-${snapshot.id.slice(0, 8)}.zip`; break; case 'wp-content': filePath = snapshot.wpContentArchivePath; fileName = `${snapshot.applicationId}-wp-content-${snapshot.id.slice(0, 8)}.tar.gz`; break; case 'database': filePath = snapshot.dbDumpPath; fileName = `${snapshot.applicationId}-database-${snapshot.id.slice(0, 8)}.sql`; break; } if (!filePath || !fs.existsSync(filePath)) { throw new NotFoundException(`Snapshot artifact "${artifact}" not found or has been deleted`); } return { filePath, fileName }; } /** * Download the CURRENT live state of the app (not from a snapshot). * Creates a temporary archive of the current source code. */ async downloadCurrentSource(applicationId: string, userId: string): Promise<{ filePath: string; fileName: string } | null> { const app = await this.applicationsService.findOne(applicationId, userId); if (app.codePath && fs.existsSync(app.codePath)) { return { filePath: app.codePath, fileName: `${app.name}-current-source.zip` }; } return null; } /** * Download the current live wp-content from the running WordPress app. */ async downloadCurrentWpContent(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); if (app.runtime !== AppRuntime.WORDPRESS) { throw new BadRequestException('Only WordPress applications have wp-content'); } const { data } = await this.kubernetesService.archiveWpContent(app); return data; } /** * Download the current live database dump. */ async downloadCurrentDatabase(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); if (app.databaseType === DatabaseType.NONE) { throw new BadRequestException('This application does not have a database'); } const { data } = await this.kubernetesService.exportDatabaseDump(app); return data; } /** * Rollback an application to a specific snapshot. * Uses K8s revision rollback for the app deployment (instant, no rebuild). * File-based restore for DB dump and wp-content. */ async rollbackToSnapshot(snapshotId: string, userId: string): Promise<{ success: boolean; details: string[] }> { const snapshot = await this.findOne(snapshotId, userId); if (snapshot.status !== SnapshotStatus.COMPLETED) { throw new BadRequestException('Cannot rollback to an incomplete or failed snapshot'); } const app = await this.applicationsService.findOne(snapshot.applicationId, userId); const details: string[] = []; // 1. Rollback K8s Deployment via revision (instant — no rebuild) // Find the revision that matches the snapshot's imageTag if (snapshot.imageTag) { try { const { revisions } = await this.kubernetesService.getDeploymentRevisions(app); const targetRevision = revisions.find((r) => r.image === snapshot.imageTag); if (targetRevision && !targetRevision.isCurrent) { const result = await this.kubernetesService.rollbackDeploymentRevision(app, targetRevision.revision); if (result.success) { details.push(`✅ App rolled back to K8s revision ${targetRevision.revision} (instant)`); this.logger.log(`Rollback ${snapshotId}: K8s revision rollback to ${targetRevision.revision}`); } else { details.push(`⚠️ K8s revision rollback failed: ${result.message}`); } } else if (targetRevision?.isCurrent) { details.push('ℹ️ App is already running the snapshot\'s image — no deployment change needed'); } else { // Revision not found — fall back to restoring source code and redeploying if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) { const uploadDir = this.configService.get('platform.uploadDir') || './uploads'; const appDir = path.join(uploadDir, app.userId, app.id); const destPath = path.join(appDir, 'source.zip'); fs.mkdirSync(appDir, { recursive: true }); fs.copyFileSync(snapshot.appArchivePath, destPath); await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any); details.push('✅ Source code restored (K8s revision expired — will need redeploy)'); this.logger.log(`Rollback ${snapshotId}: source code restored (revision not found)`); } else { details.push('⚠️ K8s revision not found and no source archive available'); } } } catch (e: any) { this.logger.warn(`Rollback ${snapshotId}: K8s revision rollback error — ${e.message}`); details.push(`⚠️ K8s rollback failed: ${e.message}`); } } // 2. Restore wp-content (WordPress) — file-based if (snapshot.wpContentArchivePath && fs.existsSync(snapshot.wpContentArchivePath)) { const archiveBuffer = fs.readFileSync(snapshot.wpContentArchivePath); const result = await this.kubernetesService.restoreWpContent(app, archiveBuffer); if (result.success) { details.push('✅ wp-content restored'); this.logger.log(`Rollback ${snapshotId}: restored wp-content`); } else { details.push(`⚠️ wp-content restore failed: ${result.logs}`); this.logger.warn(`Rollback ${snapshotId}: wp-content restore failed`); } } // 3. Restore database — file-based (pass file path for PVC-based transfer) if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) { const result = await this.kubernetesService.restoreDatabaseDump(app, snapshot.dbDumpPath); if (result.success) { details.push('✅ Database restored'); this.logger.log(`Rollback ${snapshotId}: restored database`); } else { details.push(`⚠️ Database restore failed: ${result.logs}`); this.logger.warn(`Rollback ${snapshotId}: database restore failed`); } } if (details.length === 0) { details.push('⚠️ No artifacts found in this snapshot to restore'); } return { success: true, details }; } /** * Rollback using K8s revision directly (without a snapshot). * Fastest rollback — just switches the active ReplicaSet. */ async rollbackToRevision( applicationId: string, userId: string, targetRevision: number, ): Promise<{ success: boolean; message: string }> { const app = await this.applicationsService.findOne(applicationId, userId); return this.kubernetesService.rollbackDeploymentRevision(app, targetRevision); } /** * Get K8s deployment revisions for an application. */ async getRevisions(applicationId: string, userId: string) { const app = await this.applicationsService.findOne(applicationId, userId); return this.kubernetesService.getDeploymentRevisions(app); } /** * Delete a specific snapshot. */ async deleteSnapshot(snapshotId: string, userId: string): Promise { const snapshot = await this.findOne(snapshotId, userId); this.deleteSnapshotFiles(snapshot); await this.snapshotsRepo.remove(snapshot); this.logger.log(`Deleted snapshot ${snapshotId}`); } /** * Delete all snapshots for an application (called when app is deleted). */ async deleteAllForApplication(applicationId: string): Promise { const all = await this.snapshotsRepo.find({ where: { applicationId } }); for (const snap of all) { this.deleteSnapshotFiles(snap); } await this.snapshotsRepo.delete({ applicationId }); this.logger.log(`Deleted all snapshots for app ${applicationId}`); } }