Add managed databases and services with billing-aligned upgrades.

Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-23 19:00:09 +03:30
parent 736509708b
commit 695e05f948
55 changed files with 5575 additions and 600 deletions
+568 -46
View File
@@ -9,7 +9,14 @@ import { PassThrough } from 'stream';
import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity';
import { ensureAppUrlEnv } from '../applications/app-url.util';
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget } from '../common/enums';
import {
AppRuntime,
DatabaseType,
CustomDomainStatus,
ServiceAccessTarget,
ProductType,
isManagedProductType,
} from '../common/enums';
import { HelmService } from './helm.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
@@ -111,6 +118,7 @@ export class KubernetesService implements OnModuleInit {
const storageGi = res?.storageGi ?? 1;
return {
enabled: app.enableRedis || false,
version: app.redisVersion || '7.2',
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '50m',
@@ -126,6 +134,7 @@ export class KubernetesService implements OnModuleInit {
const storageGi = res?.storageGi ?? 2;
return {
enabled: app.enableRabbitmq || false,
version: app.rabbitmqVersion || '3.13',
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '100m',
@@ -141,6 +150,98 @@ export class KubernetesService implements OnModuleInit {
return ensureAppUrlEnv(app, platformDomain);
}
private helmGlobalStorageValues(): Record<string, unknown> {
const storageClass = this.configService.get<string>('platform.storageClass') || '';
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
const storageProvisioner =
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
if (!storageClass) {
return { storageClass: '', createStorageClass: false, storageProvisioner };
}
return { storageClass, createStorageClass, storageProvisioner };
}
/** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */
private buildManagedHelmValues(app: Application): Record<string, any> {
const namespace = `user-${app.userId.split('-')[0]}`;
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const productType = app.productType;
const values: Record<string, any> = {
global: this.helmGlobalStorageValues(),
app: {
enabled: false,
name: app.name,
namespace,
runtime: app.runtime,
image: '',
port: app.port || 3000,
replicas: 0,
storageSize: app.appStorageSize || '2Gi',
},
resources: {
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
},
envVars: {},
ingress: {
enabled: false,
subdomain: app.subdomain || app.name,
domain: this.configService.get('platform.domain'),
clusterIssuer: 'letsencrypt-prod',
customDomain: '',
},
registry: { url: pullRegistryUrl },
database: {
enabled: false,
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
storageSize: app.dbStorageSize || '1Gi',
resources: {
cpuRequest: app.cpuRequest || '100m',
cpuLimit: app.cpuLimit || '500m',
memoryRequest: app.memoryRequest || '256Mi',
memoryLimit: app.memoryLimit || '512Mi',
},
},
redis: { enabled: false, storageSize: '1Gi', resources: {} },
rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} },
wordpress: { enabled: false },
elasticsearch: {
enabled: false,
logPaths: [],
ownerId: app.userId,
applicationId: app.id,
},
changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`,
};
switch (productType) {
case ProductType.MANAGED_DATABASE:
values.database.enabled = true;
values.redis.enabled = false;
values.rabbitmq.enabled = false;
break;
case ProductType.MANAGED_REDIS:
values.redis = this.buildRedisHelmBlock(app);
values.redis.enabled = true;
break;
case ProductType.MANAGED_RABBITMQ:
values.rabbitmq = this.buildRabbitmqHelmBlock(app);
values.rabbitmq.enabled = true;
break;
default:
break;
}
return values;
}
private buildHelmValues(app: Application, imageUri: string): Record<string, any> {
const domain = this.configService.get('platform.domain');
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
@@ -149,13 +250,15 @@ export class KubernetesService implements OnModuleInit {
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const values: Record<string, any> = {
global: this.helmGlobalStorageValues(),
app: {
enabled: true,
name: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
runtime: app.runtime,
image: imageUri,
port: app.port,
replicas: app.replicas,
replicas: app.replicas || 1,
storageSize: app.appStorageSize || '2Gi',
},
resources: {
@@ -170,9 +273,8 @@ export class KubernetesService implements OnModuleInit {
subdomain: app.subdomain || app.name,
domain: domain,
clusterIssuer: 'letsencrypt-prod',
customDomain: (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain
: '',
customDomain:
app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '',
},
registry: {
url: pullRegistryUrl,
@@ -185,10 +287,10 @@ export class KubernetesService implements OnModuleInit {
password: app.dbPassword || this.generatePassword(),
storageSize: app.dbStorageSize || '1Gi',
resources: {
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '256Mi',
memoryLimit: '512Mi',
cpuRequest: app.cpuRequest || '100m',
cpuLimit: app.cpuLimit || '500m',
memoryRequest: app.memoryRequest || '256Mi',
memoryLimit: app.memoryLimit || '512Mi',
},
},
wordpress: {
@@ -208,7 +310,25 @@ export class KubernetesService implements OnModuleInit {
return values;
}
/** Install or upgrade only the workload for a managed service (database, Redis, or RabbitMQ). */
async deployManagedService(app: Application): Promise<Record<string, any>> {
if (!isManagedProductType(app.productType)) {
throw new BadRequestException('deployManagedService requires a managed product type');
}
try {
return await this.deployManagedViaHelm(app);
} catch (helmError: any) {
this.logger.warn(
`Helm provision failed for managed ${app.name}, falling back to direct K8s API: ${helmError.message}`,
);
return await this.deployManagedViaK8sApi(app);
}
}
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) {
return this.deployManagedService(app);
}
// Try Helm first, fall back to direct K8s API if Helm is unavailable
try {
return await this.deployViaHelm(app, imageUri);
@@ -227,8 +347,9 @@ export class KubernetesService implements OnModuleInit {
): Promise<void> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const managed = isManagedProductType(app.productType);
const workloads = [
{ name: app.name, replicas: app.replicas || 1 },
...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []),
...(app.databaseType !== DatabaseType.NONE ? [{ name: `${app.name}-db`, replicas: 1 }] : []),
...(app.enableRedis ? [{ name: `${app.name}-redis`, replicas: 1 }] : []),
...(app.enableRabbitmq ? [{ name: `${app.name}-rabbitmq`, replicas: 1 }] : []),
@@ -337,9 +458,90 @@ export class KubernetesService implements OnModuleInit {
return { helm: { release: releaseName, namespace, stdout: result.stdout }, values };
}
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const values = this.buildManagedHelmValues(app);
const namespace = values.app.namespace;
const releaseName = app.name;
const result = await this.helmService.installOrUpgrade(
releaseName,
namespace,
values,
kubeconfig,
);
this.logger.log(`Successfully provisioned managed service ${app.name} in ${namespace} via Helm`);
return { helm: { release: releaseName, namespace, stdout: result.stdout }, values };
}
// ── Direct K8s API deployment (fallback) ──────────────────────────
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const context: ManifestContext = {
appName: app.name,
namespace,
image: '',
port: app.port || 3000,
replicas: 0,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: {},
runtime: app.runtime,
databaseType: app.databaseType,
domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir',
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: false,
redisVersion: app.redisVersion || '7.2',
enableRabbitmq: false,
rabbitmqVersion: app.rabbitmqVersion || '3.13',
enableElasticsearch: false,
elasticsearchVersion: app.elasticsearchVersion || '8.12',
logPaths: [],
ownerId: app.userId,
applicationId: app.id,
};
const manifests: Record<string, any> = {};
await this.ensureNamespace(coreApi, namespace);
switch (app.productType) {
case ProductType.MANAGED_DATABASE:
context.databaseType = app.databaseType;
manifests.database = await this.deployDatabase(coreApi, appsApi, context);
break;
case ProductType.MANAGED_REDIS:
context.enableRedis = true;
await this.deployRedis(coreApi, appsApi, context);
manifests.redis = true;
break;
case ProductType.MANAGED_RABBITMQ:
context.enableRabbitmq = true;
await this.deployRabbitmq(coreApi, appsApi, context);
manifests.rabbitmq = true;
break;
default:
throw new BadRequestException(`Unsupported managed product type: ${app.productType}`);
}
this.logger.log(`Provisioned managed service ${app.name} in ${namespace} via K8s API`);
return manifests;
}
private async deployViaK8sApi(app: Application, imageUri: string): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) {
return this.deployManagedViaK8sApi(app);
}
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const domain = this.configService.get('platform.domain');
@@ -600,6 +802,9 @@ export class KubernetesService implements OnModuleInit {
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: ctx.appStorageSize || '2Gi' } },
...(this.configService.get<string>('platform.storageClass')
? { storageClassName: this.configService.get<string>('platform.storageClass') }
: {}),
},
};
@@ -1098,20 +1303,23 @@ export class KubernetesService implements OnModuleInit {
let livenessProbe: any;
switch (dbType) {
case DatabaseType.POSTGRESQL:
case DatabaseType.POSTGRESQL: {
const pgVer = ctx.dbVersion || '16';
const pgDatabase = ctx.appName.replace(/-/g, '_');
image = `postgres:${pgVer}-alpine`;
port = 5432;
dataPath = '/var/lib/postgresql/data';
envVars = [
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
{ name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') },
{ name: 'POSTGRES_DB', value: pgDatabase },
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' } } },
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
];
readinessProbe = { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 };
livenessProbe = { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 };
const pgReady = ['pg_isready', '-U', ctx.dbUsername, '-d', pgDatabase];
readinessProbe = { exec: { command: pgReady }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 };
livenessProbe = { exec: { command: pgReady }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 };
break;
}
case DatabaseType.MYSQL:
const mysqlVer = ctx.dbVersion || '8.0';
@@ -1255,6 +1463,7 @@ export class KubernetesService implements OnModuleInit {
name: string,
size: string,
): Promise<void> {
const storageClass = this.configService.get<string>('platform.storageClass');
const pvc: k8s.V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
@@ -1262,6 +1471,7 @@ export class KubernetesService implements OnModuleInit {
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: size } },
...(storageClass ? { storageClassName: storageClass } : {}),
},
};
@@ -1520,9 +1730,26 @@ export class KubernetesService implements OnModuleInit {
this.logger.log(`RabbitMQ deployed for ${ctx.appName}`);
}
private primaryWorkloadLabel(app: Application): string {
if (isManagedProductType(app.productType)) {
switch (app.productType) {
case ProductType.MANAGED_DATABASE:
return `${app.name}-db`;
case ProductType.MANAGED_REDIS:
return `${app.name}-redis`;
case ProductType.MANAGED_RABBITMQ:
return `${app.name}-rabbitmq`;
default:
break;
}
}
return app.name;
}
async getPodLogs(app: Application): Promise<string> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const podLabel = this.primaryWorkloadLabel(app);
const pods = await coreApi.listNamespacedPod(
namespace,
@@ -1530,7 +1757,7 @@ export class KubernetesService implements OnModuleInit {
undefined,
undefined,
undefined,
`app=${app.name}`,
`app=${podLabel}`,
);
if (pods.body.items.length === 0) {
@@ -1575,9 +1802,12 @@ export class KubernetesService implements OnModuleInit {
/** 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 },
];
const managed = isManagedProductType(app.productType);
const workloads: { name: string; runningReplicas: number }[] = [];
if (!managed) {
workloads.push({ name: app.name, runningReplicas: app.replicas || 1 });
}
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
workloads.push({ name: `${app.name}-db`, runningReplicas: 1 });
@@ -1705,9 +1935,12 @@ export class KubernetesService implements OnModuleInit {
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const deploymentName = isManagedProductType(app.productType)
? this.primaryWorkloadLabel(app)
: app.name;
await appsApi.patchNamespacedDeployment(
app.name,
deploymentName,
namespace,
{
spec: {
@@ -2965,50 +3198,331 @@ export class KubernetesService implements OnModuleInit {
return { success: true, logs };
}
private isPvcResizeForbiddenError(err: unknown): boolean {
const msg =
(err as { body?: { message?: string }; message?: string })?.body?.message ||
(err as Error)?.message ||
'';
return /forbidden|resize|storageclass|dynamically provisioned/i.test(msg);
}
private async resolvePvcStorageClassName(
coreApi: k8s.CoreV1Api,
pvc: k8s.V1PersistentVolumeClaim,
): Promise<string | undefined> {
let scName = pvc.spec?.storageClassName;
if (scName) return scName;
const volumeName = pvc.spec?.volumeName;
if (!volumeName) return undefined;
try {
const pv = await coreApi.readPersistentVolume(volumeName);
scName = pv.body.spec?.storageClassName;
if (pv.body.spec?.hostPath || pv.body.spec?.nfs || pv.body.spec?.local) {
return undefined;
}
return scName;
} catch {
return undefined;
}
}
private async ensureStorageClassAllowsExpansion(
kc: k8s.KubeConfig,
storageClassName: string,
): Promise<{ ok: boolean; message?: string }> {
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
try {
const sc = await storageApi.readStorageClass(storageClassName);
if (sc.body.allowVolumeExpansion) {
return { ok: true };
}
await storageApi.patchStorageClass(
storageClassName,
{ allowVolumeExpansion: true },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
this.logger.log(`Enabled allowVolumeExpansion on StorageClass ${storageClassName}`);
return { ok: true };
} catch (e: any) {
return {
ok: false,
message: `StorageClass "${storageClassName}" does not support expansion: ${e.message}`,
};
}
}
private async patchPvcStorageSize(
coreApi: k8s.CoreV1Api,
pvcName: string,
namespace: string,
newSize: string,
): Promise<void> {
await coreApi.patchNamespacedPersistentVolumeClaim(
pvcName,
namespace,
[{ op: 'replace', path: '/spec/resources/requests/storage', value: newSize }],
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/json-patch+json' } },
);
}
/**
* Migrate DB PVC to a resize-capable StorageClass (one-time copy).
* Used when legacy PVCs were created without storageClassName.
*/
private async migrateDatabasePvcToResizableStorage(
app: Application,
newSize: string,
storageClassName: string,
): Promise<{ success: boolean; message: string }> {
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const oldPvcName = `${app.name}-db`;
const newPvcName = `${app.name}-db-resizable`;
const deploymentName = `${app.name}-db`;
try {
try {
await coreApi.readNamespacedPersistentVolumeClaim(newPvcName, namespace);
} catch {
await coreApi.createNamespacedPersistentVolumeClaim(namespace, {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: newPvcName, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName,
resources: { requests: { storage: newSize } },
},
});
}
await appsApi.patchNamespacedDeployment(
deploymentName,
namespace,
{ spec: { replicas: 0 } },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
await this.waitForDeploymentReplicas(appsApi, namespace, deploymentName, 0, 120_000);
const jobName = `${app.name}-pvc-migrate-${Date.now()}`;
await batchApi.createNamespacedJob(namespace, {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 300,
backoffLimit: 1,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'copy',
image: 'busybox:1.36',
command: [
'sh',
'-c',
'set -e; mkdir -p /dest; if [ -d /src ] && [ "$(ls -A /src 2>/dev/null)" ]; then cp -a /src/. /dest/; fi; touch /dest/.cloudhost-migrated',
],
volumeMounts: [
{ name: 'src', mountPath: '/src', readOnly: true },
{ name: 'dest', mountPath: '/dest' },
],
},
],
volumes: [
{ name: 'src', persistentVolumeClaim: { claimName: oldPvcName } },
{ name: 'dest', persistentVolumeClaim: { claimName: newPvcName } },
],
},
},
},
});
const jobOk = await this.waitForJobComplete(batchApi, coreApi, namespace, jobName, 600_000);
if (!jobOk) {
return {
success: false,
message: 'Storage migration job failed or timed out. Database was scaled down; check cluster jobs.',
};
}
const depRes = await appsApi.readNamespacedDeployment(deploymentName, namespace);
const existingVolumes = depRes.body.spec?.template?.spec?.volumes || [];
const updatedVolumes = existingVolumes.map((vol) => {
if (vol.name === 'db-storage' && vol.persistentVolumeClaim) {
return { ...vol, persistentVolumeClaim: { claimName: newPvcName } };
}
return vol;
});
if (!updatedVolumes.some((v) => v.name === 'db-storage')) {
updatedVolumes.push({
name: 'db-storage',
persistentVolumeClaim: { claimName: newPvcName },
});
}
await appsApi.patchNamespacedDeployment(
deploymentName,
namespace,
{
spec: {
replicas: 1,
template: { spec: { volumes: updatedVolumes } },
},
},
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
try {
await coreApi.deleteNamespacedPersistentVolumeClaim(oldPvcName, namespace);
} catch {
this.logger.warn(`Could not delete old PVC ${oldPvcName} after migration`);
}
this.logger.log(`Migrated database PVC ${oldPvcName}${newPvcName} (${newSize})`);
return {
success: true,
message: `Database storage migrated to expandable disk and set to ${newSize}. A brief restart was required.`,
};
} catch (e: any) {
this.logger.error(`PVC migration failed for ${app.name}: ${e.message}`);
try {
await appsApi.patchNamespacedDeployment(
deploymentName,
namespace,
{ spec: { replicas: 1 } },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
} catch {}
return {
success: false,
message: e.body?.message || e.message || 'Failed to migrate database storage',
};
}
}
private async waitForDeploymentReplicas(
appsApi: k8s.AppsV1Api,
namespace: string,
name: string,
target: number,
timeoutMs: number,
): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const dep = await appsApi.readNamespacedDeployment(name, namespace);
const ready = dep.body.status?.readyReplicas ?? 0;
const replicas = dep.body.spec?.replicas ?? 0;
if (target === 0 && replicas === 0) return true;
if (target > 0 && ready >= target && replicas >= target) return true;
} catch {}
await new Promise((r) => setTimeout(r, 3000));
}
return false;
}
private async waitForJobComplete(
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
namespace: string,
jobName: string,
timeoutMs: number,
): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const job = await batchApi.readNamespacedJob(jobName, namespace);
const succeeded = job.body.status?.succeeded ?? 0;
const failed = job.body.status?.failed ?? 0;
if (succeeded > 0) return true;
if (failed > 0) return false;
} catch {}
await new Promise((r) => setTimeout(r, 3000));
}
return false;
}
/**
* Resize (expand) the database PVC for an application.
* K8s only supports PVC expansion, not shrinking.
*/
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-db`;
try {
// Read current PVC to check current size
const currentPvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
const currentSize = currentPvc.body.spec?.resources?.requests?.storage || '1Gi';
const currentGi = parseInt(currentSize.replace('Gi', ''), 10) || 1;
const newGi = parseInt(newSize.replace('Gi', ''), 10) || 1;
const currentGi = parseInt(String(currentSize).replace(/Gi/i, ''), 10) || 1;
const newGi = parseInt(String(newSize).replace(/Gi/i, ''), 10) || 1;
if (newGi <= currentGi) {
return { success: false, message: `New size (${newSize}) must be larger than current size (${currentSize})` };
}
// Patch PVC to expand
const patch = [
{
op: 'replace',
path: '/spec/resources/requests/storage',
value: newSize,
},
];
const scName = await this.resolvePvcStorageClassName(coreApi, currentPvc.body);
if (scName) {
const scCheck = await this.ensureStorageClassAllowsExpansion(kc, scName);
if (!scCheck.ok) {
return { success: false, message: scCheck.message || 'StorageClass does not allow expansion' };
}
}
await coreApi.patchNamespacedPersistentVolumeClaim(
pvcName,
namespace,
patch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/json-patch+json' } },
);
this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`);
return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` };
try {
await this.patchPvcStorageSize(coreApi, pvcName, namespace, newSize);
this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`);
return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` };
} catch (patchErr: any) {
if (!this.isPvcResizeForbiddenError(patchErr)) {
throw patchErr;
}
const targetSc = this.configService.get<string>('platform.storageClass');
if (!targetSc) {
return {
success: false,
message:
'This disk cannot be expanded in place. Set PLATFORM_STORAGE_CLASS (e.g. cloudhost-expandable) and redeploy, or contact support.',
};
}
if (pvcName.endsWith('-resizable')) {
return {
success: false,
message: patchErr.body?.message || patchErr.message || 'Failed to resize database storage',
};
}
this.logger.warn(`In-place resize failed for ${pvcName}, migrating to StorageClass ${targetSc}`);
return this.migrateDatabasePvcToResizableStorage(app, newSize, targetSc);
}
} catch (e: any) {
this.logger.error(`Failed to resize PVC ${pvcName}: ${e.message}`);
return { success: false, message: e.body?.message || e.message || 'Failed to resize database storage' };
@@ -3297,7 +3811,10 @@ export class KubernetesService implements OnModuleInit {
*
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
*/
async exportDatabaseDump(app: Application): Promise<{ data: Buffer | null; logs: string }> {
async exportDatabaseDump(
app: Application,
onProgress?: (percent: number) => void,
): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
@@ -3358,6 +3875,9 @@ export class KubernetesService implements OnModuleInit {
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
const elapsed = Date.now() - start;
const waitPct = Math.min(75, Math.round((elapsed / timeout) * 75));
onProgress?.(10 + waitPct);
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0) {
@@ -3370,6 +3890,7 @@ export class KubernetesService implements OnModuleInit {
const logRes = await coreApi.readNamespacedPodLog(podName, namespace, 'dump', false, undefined, undefined, undefined, undefined, undefined, 50);
if (logRes.body?.includes('DUMP_DONE')) {
dumpDone = true;
onProgress?.(88);
break;
}
} catch {}
@@ -3418,6 +3939,7 @@ export class KubernetesService implements OnModuleInit {
if (chunks.length > 0) {
dumpBuffer = Buffer.concat(chunks);
onProgress?.(95);
this.logger.log(`DB dump retrieved: ${dumpBuffer.length} bytes`);
}
} catch (e: any) {