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
@@ -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 = '';