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:
@@ -221,8 +221,12 @@ export class KubernetesService implements OnModuleInit {
|
||||
name: ctx.appName,
|
||||
namespace: ctx.namespace,
|
||||
labels: { app: ctx.appName, runtime: ctx.runtime },
|
||||
annotations: {
|
||||
'kubernetes.io/change-cause': `Deploy ${ctx.image} at ${new Date().toISOString()}`,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
revisionHistoryLimit: 10,
|
||||
replicas: ctx.replicas,
|
||||
selector: { matchLabels: { app: ctx.appName } },
|
||||
template: {
|
||||
@@ -1422,6 +1426,146 @@ export class KubernetesService implements OnModuleInit {
|
||||
return { success: succeeded && !failed, logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out') };
|
||||
}
|
||||
|
||||
// ─── K8s Revision-based Rollback ─────────────────────
|
||||
|
||||
/**
|
||||
* Get the list of deployment revisions (ReplicaSets) for an application.
|
||||
* Returns up to 10 revisions sorted newest-first with image, change-cause, and creation time.
|
||||
*/
|
||||
async getDeploymentRevisions(app: Application): Promise<{
|
||||
revisions: Array<{
|
||||
revision: number;
|
||||
image: string;
|
||||
changeCause: string;
|
||||
createdAt: string;
|
||||
replicas: number;
|
||||
isCurrent: boolean;
|
||||
}>;
|
||||
currentRevision: number;
|
||||
}> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
// Get the deployment to find the current revision
|
||||
let currentRevision = 0;
|
||||
try {
|
||||
const dep = await appsApi.readNamespacedDeployment(app.name, namespace);
|
||||
currentRevision = parseInt(dep.body.metadata?.annotations?.['deployment.kubernetes.io/revision'] || '0', 10);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Could not read deployment for ${app.name}: ${e.message}`);
|
||||
return { revisions: [], currentRevision: 0 };
|
||||
}
|
||||
|
||||
// List ReplicaSets owned by this deployment
|
||||
const rsList = await appsApi.listNamespacedReplicaSet(
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`app=${app.name}`,
|
||||
);
|
||||
|
||||
const revisions = rsList.body.items
|
||||
.filter((rs) => {
|
||||
// Must be owned by our deployment
|
||||
const owners = rs.metadata?.ownerReferences || [];
|
||||
return owners.some((o) => o.kind === 'Deployment' && o.name === app.name);
|
||||
})
|
||||
.map((rs) => {
|
||||
const rev = parseInt(rs.metadata?.annotations?.['deployment.kubernetes.io/revision'] || '0', 10);
|
||||
const image = rs.spec?.template?.spec?.containers?.[0]?.image || 'unknown';
|
||||
const changeCause = rs.metadata?.annotations?.['kubernetes.io/change-cause'] || '';
|
||||
const createdAt = rs.metadata?.creationTimestamp?.toISOString() || '';
|
||||
const replicas = rs.status?.replicas || 0;
|
||||
return {
|
||||
revision: rev,
|
||||
image,
|
||||
changeCause,
|
||||
createdAt,
|
||||
replicas,
|
||||
isCurrent: rev === currentRevision,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.revision - a.revision)
|
||||
.slice(0, 10);
|
||||
|
||||
return { revisions, currentRevision };
|
||||
}
|
||||
|
||||
/**
|
||||
* Rollback a K8s Deployment to a specific revision using the K8s API.
|
||||
* This is equivalent to `kubectl rollout undo deployment/<name> --to-revision=<rev>`.
|
||||
* It's instant — no rebuild needed, K8s just switches the active ReplicaSet.
|
||||
*/
|
||||
async rollbackDeploymentRevision(
|
||||
app: Application,
|
||||
targetRevision: number,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
const { appsApi } = await this.getK8sClient(app.clusterId);
|
||||
const namespace = `user-${app.userId.split('-')[0]}`;
|
||||
|
||||
try {
|
||||
// Read the target ReplicaSet's pod template
|
||||
const rsList = await appsApi.listNamespacedReplicaSet(
|
||||
namespace,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
`app=${app.name}`,
|
||||
);
|
||||
|
||||
const targetRs = rsList.body.items.find((rs) => {
|
||||
const rev = parseInt(rs.metadata?.annotations?.['deployment.kubernetes.io/revision'] || '0', 10);
|
||||
const owners = rs.metadata?.ownerReferences || [];
|
||||
return rev === targetRevision && owners.some((o) => o.kind === 'Deployment' && o.name === app.name);
|
||||
});
|
||||
|
||||
if (!targetRs) {
|
||||
return { success: false, message: `Revision ${targetRevision} not found` };
|
||||
}
|
||||
|
||||
// Get the pod template from the target ReplicaSet
|
||||
const targetTemplate = targetRs.spec?.template;
|
||||
if (!targetTemplate) {
|
||||
return { success: false, message: 'Could not read pod template from target revision' };
|
||||
}
|
||||
|
||||
// Patch the deployment with the target revision's pod template
|
||||
// This triggers a new rollout that uses the same image/config as the target revision
|
||||
const patch = {
|
||||
metadata: {
|
||||
annotations: {
|
||||
'kubernetes.io/change-cause': `Rollback to revision ${targetRevision} at ${new Date().toISOString()}`,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
template: targetTemplate,
|
||||
},
|
||||
};
|
||||
|
||||
await appsApi.patchNamespacedDeployment(
|
||||
app.name,
|
||||
namespace,
|
||||
patch,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
|
||||
);
|
||||
|
||||
const image = targetTemplate.spec?.containers?.[0]?.image || 'unknown';
|
||||
this.logger.log(`Rolled back ${app.name} to revision ${targetRevision} (image: ${image})`);
|
||||
return { success: true, message: `Rolled back to revision ${targetRevision} (image: ${image})` };
|
||||
} catch (e: any) {
|
||||
this.logger.error(`Failed to rollback ${app.name}: ${e.body?.message || e.message}`);
|
||||
return { success: false, message: e.body?.message || e.message };
|
||||
}
|
||||
}
|
||||
|
||||
private generatePassword(length = 24): string {
|
||||
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%';
|
||||
let password = '';
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Delete,
|
||||
Param,
|
||||
Query,
|
||||
Body,
|
||||
Res,
|
||||
UseGuards,
|
||||
Request,
|
||||
@@ -27,6 +28,25 @@ export class SnapshotsController {
|
||||
|
||||
constructor(private readonly snapshotsService: SnapshotsService) {}
|
||||
|
||||
// ─── K8s Revision-based Rollback (instant) ─────────
|
||||
|
||||
@Get('applications/:appId/revisions')
|
||||
@ApiOperation({ summary: 'Get K8s deployment revision history (up to 10)' })
|
||||
async getRevisions(@Param('appId') appId: string, @Request() req: any) {
|
||||
return this.snapshotsService.getRevisions(appId, req.user.id);
|
||||
}
|
||||
|
||||
@Post('applications/:appId/revisions/:revision/rollback')
|
||||
@ApiOperation({ summary: 'Rollback app deployment to a K8s revision (instant, no rebuild)' })
|
||||
async rollbackToRevision(
|
||||
@Param('appId') appId: string,
|
||||
@Param('revision') revision: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const result = await this.snapshotsService.rollbackToRevision(appId, req.user.id, parseInt(revision, 10));
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── Snapshot CRUD ──────────────────────────────────
|
||||
|
||||
@Post('applications/:appId')
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user