From 7c7e8ae254b4a54fe296be33ebe5e936d4a4b9a1 Mon Sep 17 00:00:00 2001 From: keyhan Date: Fri, 15 May 2026 01:03:52 +0330 Subject: [PATCH] fix(deploy): persist replica snapshot before stop for restore on start Read live deployment replica counts from K8s before scaling to zero, store them on the application as suspendedReplicas, and use that snapshot when resuming so Start restores the pre-stop replica layout. Co-authored-by: Cursor --- .../src/applications/applications.service.ts | 19 ++++++++ .../entities/application.entity.ts | 4 ++ .../src/deployments/deployments.service.ts | 4 +- backend/src/kubernetes/kubernetes.service.ts | 46 ++++++++++++++++--- .../src/lifecycle/app-lifecycle.service.ts | 8 +++- 5 files changed, 73 insertions(+), 8 deletions(-) diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index fddab06..e25ce30 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -189,6 +189,25 @@ export class ApplicationsService { return this.appsRepository.save(app); } + async saveSuspendedReplicas( + id: string, + snapshot: Record, + ): Promise { + const app = await this.findOne(id); + app.suspendedReplicas = snapshot; + app.suspendedAt = new Date(); + if (snapshot[app.name] !== undefined) { + app.replicas = snapshot[app.name]; + } + return this.appsRepository.save(app); + } + + async clearSuspendedReplicas(id: string): Promise { + const app = await this.findOne(id); + app.suspendedReplicas = undefined; + return this.appsRepository.save(app); + } + async uploadCode(id: string, userId: string, file: Express.Multer.File): Promise { if (!file) { throw new BadRequestException('No file uploaded'); diff --git a/backend/src/applications/entities/application.entity.ts b/backend/src/applications/entities/application.entity.ts index 197653f..c793723 100644 --- a/backend/src/applications/entities/application.entity.ts +++ b/backend/src/applications/entities/application.entity.ts @@ -159,6 +159,10 @@ export class Application { @Column({ type: 'timestamptz', nullable: true }) suspendedAt: Date; // When the app was suspended (pods scaled to 0) + /** Per-deployment replica counts captured before suspend (deployment name → replicas). */ + @Column({ type: 'jsonb', nullable: true }) + suspendedReplicas?: Record; + @Column({ type: 'timestamptz', nullable: true }) scheduledDeletionAt: Date; // When the app will be permanently deleted diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index df5abf7..c016a8a 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -240,7 +240,8 @@ export class DeploymentsService { async stopDeployment(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); - await this.kubernetesService.suspendApplication(app); + const snapshot = await this.kubernetesService.suspendApplication(app); + await this.applicationsService.saveSuspendedReplicas(app.id, snapshot); const latest = await this.deploymentsRepository.findOne({ where: { applicationId }, @@ -257,6 +258,7 @@ export class DeploymentsService { async startDeployment(applicationId: string, userId: string): Promise { const app = await this.applicationsService.findOne(applicationId, userId); await this.kubernetesService.resumeApplication(app); + await this.applicationsService.clearSuspendedReplicas(app.id); const latest = await this.deploymentsRepository.findOne({ where: { applicationId }, diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 114e021..22c947e 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -1353,7 +1353,7 @@ export class KubernetesService implements OnModuleInit { ); } - /** All K8s Deployments that belong to an application stack. */ + /** All K8s Deployments that belong to an application stack (default replica targets). */ private getApplicationWorkloadDeployments(app: Application): { name: string; runningReplicas: number }[] { const workloads: { name: string; runningReplicas: number }[] = [ { name: app.name, runningReplicas: app.replicas || 1 }, @@ -1372,6 +1372,35 @@ export class KubernetesService implements OnModuleInit { return workloads; } + private resolveWorkloadReplicas(app: Application): { name: string; runningReplicas: number }[] { + const workloads = this.getApplicationWorkloadDeployments(app); + const saved = app.suspendedReplicas; + if (!saved) return workloads; + + return workloads.map((workload) => ({ + name: workload.name, + runningReplicas: saved[workload.name] ?? workload.runningReplicas, + })); + } + + async captureWorkloadReplicaSnapshot(app: Application): Promise> { + const { appsApi } = await this.getK8sClient(app.clusterId); + const namespace = `user-${app.userId.split('-')[0]}`; + const snapshot: Record = {}; + + for (const workload of this.getApplicationWorkloadDeployments(app)) { + try { + const deployment = await appsApi.readNamespacedDeployment(workload.name, namespace); + snapshot[workload.name] = deployment.body.spec?.replicas ?? workload.runningReplicas; + } catch (e: any) { + if (e?.response?.statusCode === 404) continue; + snapshot[workload.name] = workload.runningReplicas; + } + } + + return snapshot; + } + private async patchDeploymentReplicas( appsApi: k8s.AppsV1Api, namespace: string, @@ -1393,15 +1422,18 @@ export class KubernetesService implements OnModuleInit { /** * Suspend an application by scaling all stack deployments to 0 replicas. - * Keeps PVCs, Services, and Ingress in place. + * Returns the replica snapshot captured before scaling. */ - async suspendApplication(app: Application): Promise { + async suspendApplication(app: Application): Promise> { const { appsApi } = await this.getK8sClient(app.clusterId); const namespace = `user-${app.userId.split('-')[0]}`; this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`); - for (const workload of this.getApplicationWorkloadDeployments(app)) { + const snapshot = await this.captureWorkloadReplicaSnapshot(app); + const workloads = this.getApplicationWorkloadDeployments(app); + + for (const workload of workloads) { try { await this.patchDeploymentReplicas(appsApi, namespace, workload.name, 0); this.logger.log(`Scaled ${workload.name} to 0 replicas`); @@ -1411,10 +1443,12 @@ export class KubernetesService implements OnModuleInit { } } } + + return snapshot; } /** - * Resume a suspended application by scaling all stack deployments back up. + * Resume a suspended application using saved replica counts when available. */ async resumeApplication(app: Application): Promise { const { appsApi } = await this.getK8sClient(app.clusterId); @@ -1422,7 +1456,7 @@ export class KubernetesService implements OnModuleInit { this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`); - const workloads = this.getApplicationWorkloadDeployments(app); + const workloads = this.resolveWorkloadReplicas(app); const dependencies = workloads.filter((w) => w.name !== app.name); const main = workloads.find((w) => w.name === app.name); diff --git a/backend/src/lifecycle/app-lifecycle.service.ts b/backend/src/lifecycle/app-lifecycle.service.ts index 695eb6b..eb1e18f 100644 --- a/backend/src/lifecycle/app-lifecycle.service.ts +++ b/backend/src/lifecycle/app-lifecycle.service.ts @@ -74,6 +74,8 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { if (app.latestImageTag) { try { await this.kubernetesService.resumeApplication(app); + app.suspendedReplicas = undefined; + await this.appRepo.save(app); this.logger.log(`Reactivated ${app.name} — resumed with ${app.replicas} replicas, expires ${expiresAt.toISOString()}`); } catch (e: any) { this.logger.warn(`Failed to resume ${app.name} on reactivation: ${e.message}`); @@ -138,7 +140,11 @@ export class AppLifecycleService implements OnModuleInit, OnModuleDestroy { // Suspend: scale app and database to 0 try { - await this.kubernetesService.suspendApplication(app); + const snapshot = await this.kubernetesService.suspendApplication(app); + app.suspendedReplicas = snapshot; + if (snapshot[app.name] !== undefined) { + app.replicas = snapshot[app.name]; + } } catch (e: any) { this.logger.warn(`Failed to suspend ${app.name} in K8s: ${e.message}`); }