feat: use Kubernetes revision-based rollback for instant app rollback

- Add revisionHistoryLimit: 10 and change-cause annotation to K8s deployments
- Add getDeploymentRevisions() to list ReplicaSet revision history
- Add rollbackDeploymentRevision() using K8s API (instant, no rebuild)
- Refactor snapshot rollback: use K8s revision for app, keep file-based DB/wp-content restore
- Add GET /snapshots/applications/:appId/revisions endpoint
- Add POST /snapshots/applications/:appId/revisions/:rev/rollback endpoint
- Add K8sRevision and K8sRevisionData types to frontend
- Redesign UI with two tabs: K8s Revisions (instant) + File Snapshots (full backup)
- K8s Revisions tab shows deployment history with one-click instant rollback
- File Snapshots tab retains download, DB restore, and wp-content restore
This commit is contained in:
keyhan
2026-04-08 02:26:51 +03:30
parent d3a5240528
commit d01a0a5e8c
6 changed files with 517 additions and 171 deletions
+61 -14
View File
@@ -287,7 +287,8 @@ export class SnapshotsService {
/**
* Rollback an application to a specific snapshot.
* Restores: source code, wp-content (WordPress), and database.
* 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);
@@ -299,20 +300,45 @@ export class SnapshotsService {
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`);
// 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<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);
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)
// 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);
@@ -325,7 +351,7 @@ export class SnapshotsService {
}
}
// 3. Restore database
// 3. Restore database — file-based
if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) {
const dumpBuffer = fs.readFileSync(snapshot.dbDumpPath);
const result = await this.kubernetesService.restoreDatabaseDump(app, dumpBuffer);
@@ -345,6 +371,27 @@ export class SnapshotsService {
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.
*/