feat: add snapshot/rollback system with download capability

- Add AppSnapshot entity with type (pre_deploy/manual), status tracking, and file paths
- Add SnapshotsService with create, capture, rollback, prune (max 10), and download logic
- Add SnapshotsController with REST endpoints for CRUD, rollback, and file downloads
- Add K8s methods: exportDatabaseDump, archiveWpContent, restoreWpContent
- Auto-create pre-deploy snapshots before each deployment for rollback safety
- Support downloading current live state (source, wp-content, database) without snapshots
- Add snapshot management UI in app detail page with create, rollback, download, delete
- Wire circular dependencies with forwardRef between Deployments and Snapshots modules
This commit is contained in:
keyhan
2026-04-08 01:55:00 +03:30
parent ac5278d73b
commit d3a5240528
11 changed files with 1286 additions and 3 deletions
+369
View File
@@ -0,0 +1,369 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject, forwardRef } 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 } from '../common/enums';
const MAX_SNAPSHOTS = 10;
@Injectable()
export class SnapshotsService {
private readonly logger = new Logger(SnapshotsService.name);
constructor(
@InjectRepository(AppSnapshot)
private snapshotsRepo: Repository<AppSnapshot>,
@Inject(forwardRef(() => ApplicationsService))
private applicationsService: ApplicationsService,
private kubernetesService: KubernetesService,
private configService: ConfigService,
) {}
/**
* 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<AppSnapshot> {
const app = await this.applicationsService.findOne(applicationId, userId);
const snapshot = this.snapshotsRepo.create({
applicationId: app.id,
createdBy: userId,
type,
status: SnapshotStatus.IN_PROGRESS,
label: label || `Snapshot ${new Date().toLocaleString()}`,
imageTag: app.latestImageTag || undefined,
hasDatabase: app.databaseType !== DatabaseType.NONE,
} as Partial<AppSnapshot>);
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<AppSnapshot> {
// 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<AppSnapshot>);
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 captureSnapshot(snapshotId: string, app: any): Promise<void> {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const snapshotDir = path.join(uploadDir, app.userId, app.id, 'snapshots', snapshotId);
fs.mkdirSync(snapshotDir, { recursive: true });
const updates: Partial<AppSnapshot> = {};
try {
// 1. Copy current source code zip
if (app.codePath && fs.existsSync(app.codePath)) {
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) {
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) {
try {
const { data, logs } = await this.kubernetesService.exportDatabaseDump(app);
if (data && data.length > 0) {
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}`);
}
} catch (e: any) {
this.logger.warn(`Snapshot ${snapshotId}: DB dump failed — ${e.message}`);
}
}
updates.status = SnapshotStatus.COMPLETED;
} catch (error: any) {
updates.status = SnapshotStatus.FAILED;
updates.errorMessage = error.message;
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<void> {
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<AppSnapshot[]> {
// 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<AppSnapshot> {
const snapshot = await this.snapshotsRepo.findOne({
where: { id: snapshotId },
relations: ['application'],
});
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<Buffer | null> {
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<Buffer | null> {
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.
* Restores: source code, wp-content (WordPress), and database.
*/
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. Restore source code
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
const uploadDir = this.configService.get<string>('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);
// Update app codePath in DB
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
details.push('✅ Source code restored');
this.logger.log(`Rollback ${snapshotId}: restored source code`);
}
// 2. Restore wp-content (WordPress)
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
if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) {
const dumpBuffer = fs.readFileSync(snapshot.dbDumpPath);
const result = await this.kubernetesService.restoreDatabaseDump(app, dumpBuffer);
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 };
}
/**
* Delete a specific snapshot.
*/
async deleteSnapshot(snapshotId: string, userId: string): Promise<void> {
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<void> {
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}`);
}
}