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 <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-15 01:03:52 +03:30
parent 195b3f5bab
commit 7c7e8ae254
5 changed files with 73 additions and 8 deletions
+40 -6
View File
@@ -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<Record<string, number>> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const snapshot: Record<string, number> = {};
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<void> {
async suspendApplication(app: Application): Promise<Record<string, number>> {
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<void> {
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);