fix(platform): apply production hardening from audit plan

Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-07-02 19:35:07 +03:30
parent 34c110be6a
commit 22359be40e
55 changed files with 4883 additions and 381 deletions
+201 -166
View File
@@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
@@ -17,6 +18,7 @@ import { HelmService } from './helm.service';
import { RegistryService } from './registry.service';
import { K8sClientService } from './k8s-client.service';
import { K8sLifecycleService } from './k8s-lifecycle.service';
import { userNamespace, userIdSlug } from './k8s-workload.util';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
const execFileAsync = promisify(execFile);
@@ -209,8 +211,27 @@ export class KubernetesService implements OnModuleInit {
}
/** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */
/**
* Return the app's database password, generating and PERSISTING one if it is
* missing. Without persistence a fresh password would be generated on every
* helm upgrade, breaking auth against the database's persisted volume.
*/
private ensureDbPassword(app: Application): string {
if (!app.dbPassword) {
app.dbPassword = this.generatePassword();
this.deploymentsRepository.manager
.getRepository(Application)
.update(app.id, { dbPassword: app.dbPassword })
.catch((e: any) =>
this.logger.warn(`Failed to persist generated dbPassword for ${app.name}: ${e.message}`),
);
this.logger.warn(`App ${app.name} had no dbPassword — generated and persisted one`);
}
return app.dbPassword;
}
private buildManagedHelmValues(app: Application): Record<string, any> {
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const pullRegistryUrl = this.registryService.getRegistryUrl();
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const productType = app.productType;
@@ -247,7 +268,10 @@ export class KubernetesService implements OnModuleInit {
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
password:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app),
},
@@ -260,6 +284,7 @@ export class KubernetesService implements OnModuleInit {
ownerId: app.userId,
applicationId: app.id,
},
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`,
};
@@ -287,7 +312,7 @@ export class KubernetesService implements OnModuleInit {
private buildHelmValues(app: Application, imageUri: string, previewNumber?: string | null): Record<string, any> {
const domain = this.configService.get('platform.domain');
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0];
const namespacePrefix = userIdSlug(app.userId);
const previewHost = previewNumber && !app.customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
@@ -299,7 +324,7 @@ export class KubernetesService implements OnModuleInit {
app: {
enabled: true,
name: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
namespace: this.getUserNamespace(app.userId),
runtime: app.runtime,
image: imageUri,
port: app.port,
@@ -330,7 +355,7 @@ export class KubernetesService implements OnModuleInit {
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
password: hasDb ? this.ensureDbPassword(app) : '',
storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app),
},
@@ -344,10 +369,11 @@ export class KubernetesService implements OnModuleInit {
logPaths: app.logPaths || [],
ownerId: app.userId,
applicationId: app.id,
elasticPassword: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure',
fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer',
kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System',
elasticPassword: this.configService.get<string>('elasticsearch.password'),
fluentbitPassword: this.configService.get<string>('elasticsearch.fluentbitPassword'),
kibanaPassword: this.configService.get<string>('elasticsearch.kibanaPassword'),
},
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
};
@@ -389,7 +415,7 @@ export class KubernetesService implements OnModuleInit {
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const managed = isManagedProductType(app.productType);
const workloads = [
...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []),
@@ -429,7 +455,7 @@ export class KubernetesService implements OnModuleInit {
async updateIngress(app: Application): Promise<void> {
const domain = this.configService.get('platform.domain');
const subdomain = app.subdomain || app.name;
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const customDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : undefined;
// When there's no verified custom domain, restore the stable preview host so
@@ -528,7 +554,7 @@ export class KubernetesService implements OnModuleInit {
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const context: ManifestContext = {
appName: app.name,
namespace,
@@ -545,7 +571,10 @@ export class KubernetesService implements OnModuleInit {
domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir',
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
@@ -602,7 +631,7 @@ export class KubernetesService implements OnModuleInit {
const context: ManifestContext = {
appName: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
namespace: this.getUserNamespace(app.userId),
image: imageUri,
port: app.port,
replicas: app.replicas,
@@ -616,7 +645,10 @@ export class KubernetesService implements OnModuleInit {
domain: domain,
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
@@ -1172,10 +1204,10 @@ export class KubernetesService implements OnModuleInit {
/** Replicate logging credentials into the app namespace for Fluent Bit sidecars. */
private async ensureElasticsearchCredentialsSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
const name = 'elasticsearch-credentials';
const stringData = {
ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || 'CloudHost2024!Secure',
FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || 'FluentBit2024!Writer',
KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || 'Kibana2024!System',
const stringData: { [key: string]: string } = {
ELASTIC_PASSWORD: this.configService.get<string>('elasticsearch.password') || '',
FLUENTBIT_PASSWORD: this.configService.get<string>('elasticsearch.fluentbitPassword') || '',
KIBANA_SYSTEM_PASSWORD: this.configService.get<string>('elasticsearch.kibanaPassword') || '',
};
try {
@@ -1512,7 +1544,7 @@ export class KubernetesService implements OnModuleInit {
}
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || ctx.domain;
const namespacePrefix = ctx.ownerId.split('-')[0];
const namespacePrefix = userIdSlug(ctx.ownerId);
const previewHost = previewNumber && !customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
if (previewHost) {
rules.push({
@@ -2228,7 +2260,7 @@ export class KubernetesService implements OnModuleInit {
async scaleDeployment(app: Application, replicas: number): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
}
@@ -2268,7 +2300,7 @@ export class KubernetesService implements OnModuleInit {
async captureWorkloadReplicaSnapshot(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const snapshot: Record<string, number> = {};
for (const workload of this.getApplicationWorkloadDeployments(app)) {
@@ -2297,7 +2329,7 @@ export class KubernetesService implements OnModuleInit {
*/
async suspendApplication(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
@@ -2323,7 +2355,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resumeApplication(app: Application): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
@@ -2355,7 +2387,7 @@ export class KubernetesService implements OnModuleInit {
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
await appsApi.patchNamespacedDeployment(
@@ -2550,7 +2582,7 @@ export class KubernetesService implements OnModuleInit {
*/
async getResourceUsage(app: Application): Promise<any> {
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const workloads: any[] = [];
@@ -2640,7 +2672,7 @@ export class KubernetesService implements OnModuleInit {
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const target = this.workloadDeploymentTarget(app, workload);
if (!target) {
@@ -2685,7 +2717,7 @@ export class KubernetesService implements OnModuleInit {
}
getUserNamespace(userId: string): string {
return `user-${userId.split('-')[0]}`;
return userNamespace(userId);
}
private getClusterHostIp(kc: k8s.KubeConfig): string {
@@ -2979,7 +3011,7 @@ export class KubernetesService implements OnModuleInit {
const subdomain = app.subdomain || app.name;
const verifiedCustomDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : null;
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0];
const namespacePrefix = userIdSlug(app.userId);
let ingressUrl = `https://${subdomain}.${domain}`;
if (verifiedCustomDomain) {
@@ -3362,7 +3394,7 @@ export class KubernetesService implements OnModuleInit {
*/
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const dbLabel = `${app.name}-db`;
const start = Date.now();
@@ -3469,14 +3501,12 @@ export class KubernetesService implements OnModuleInit {
async restoreDatabaseDump(app: Application, dumpFilePath: string): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const dbName = `${app.name}-db`;
const ts = Date.now();
const pvcName = `${app.name}-db-dump-${ts}`;
const helperPodName = `${pvcName}-helper`;
const jobName = `${app.name}-db-restore-${ts}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const dumpSize = fs.statSync(dumpFilePath).size;
const pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024)));
@@ -3562,14 +3592,8 @@ export class KubernetesService implements OnModuleInit {
} catch {}
}
// ── 4. Build restore command ──
const command = isPostgres
? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" psql -h ${dbName} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`]
: ['sh', '-c', `mysql -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`];
const defaultDbVer = isPostgres ? '16' : '8.0';
const restoreDbVer = app.dbVersion || defaultDbVer;
const image = isPostgres ? `postgres:${restoreDbVer}-alpine` : `mysql:${restoreDbVer}`;
// ── 4. Build restore command (per database type) ──
const { image, restoreCommand: command } = this.databaseDumpSpec(app, dbName);
// ── 5. Create the restore Job ──
const job: k8s.V1Job = {
@@ -3777,7 +3801,7 @@ export class KubernetesService implements OnModuleInit {
private async migrateDatabasePvcToResizableStorage(app: Application, newSize: string, storageClassName: string): Promise<{ success: boolean; message: string }> {
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const oldPvcName = `${app.name}-db`;
const newPvcName = `${app.name}-db-resizable`;
const deploymentName = `${app.name}-db`;
@@ -3952,7 +3976,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-db`;
try {
@@ -4025,7 +4049,7 @@ export class KubernetesService implements OnModuleInit {
async getDatabasePvcSize(app: Application): Promise<string> {
try {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-db`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
@@ -4051,7 +4075,7 @@ export class KubernetesService implements OnModuleInit {
totalUsedGb: number;
}> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const result = {
database: null as StorageUsageSlice | null,
@@ -4260,7 +4284,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resizeNamedPvc(app: Application, pvcName: string, newSize: string, label: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
try {
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
@@ -4317,7 +4341,7 @@ export class KubernetesService implements OnModuleInit {
*/
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
// Try new unified name first, then legacy wp-content name
let pvcName = `${app.name}-storage`;
@@ -4374,6 +4398,60 @@ export class KubernetesService implements OnModuleInit {
// ─── Snapshot helpers ───────────────────────────────
/**
* Per-database tooling for dump/restore jobs. `dumpCommand` writes to
* `outputPath`; `restoreCommand` reads from `/dump/dump.sql` (the copied
* dump file keeps that name regardless of format — mongodump archives are
* binary but mongorestore does not care about the extension).
*/
private databaseDumpSpec(app: Application, dbHost: string): {
image: string;
outputPath: string;
dumpCommand: string[];
restoreCommand: string[];
} {
const dbDatabase = app.name.replace(/-/g, '_');
switch (app.databaseType) {
case DatabaseType.POSTGRESQL: {
const image = `postgres:${app.dbVersion || '16'}-alpine`;
return {
image,
outputPath: '/dump/output.sql',
dumpCommand: ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbHost} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" psql -h ${dbHost} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`],
};
}
case DatabaseType.MONGODB: {
const image = `mongo:${app.dbVersion || '7.0'}`;
const auth = `-u "$DB_USER" -p "$DB_PASSWORD" --authenticationDatabase admin`;
return {
image,
outputPath: '/dump/output.archive',
dumpCommand: ['sh', '-c', `mongodump --host ${dbHost} ${auth} --db ${dbDatabase} --archive=/dump/output.archive --gzip 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `mongorestore --host ${dbHost} ${auth} --nsInclude '${dbDatabase}.*' --archive=/dump/dump.sql --gzip --drop 2>&1`],
};
}
case DatabaseType.MARIADB: {
const image = `mariadb:${app.dbVersion || '11'}`;
return {
image,
outputPath: '/dump/output.sql',
dumpCommand: ['sh', '-c', `mariadb-dump -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `mariadb -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`],
};
}
default: {
const image = `mysql:${app.dbVersion || '8.0'}`;
return {
image,
outputPath: '/dump/output.sql',
dumpCommand: ['sh', '-c', `mysqldump -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`],
restoreCommand: ['sh', '-c', `mysql -h ${dbHost} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`],
};
}
}
}
/**
* Export (dump) the application database to a local file via a K8s Job.
* Returns the dump as a Buffer, or null on failure.
@@ -4383,20 +4461,12 @@ export class KubernetesService implements OnModuleInit {
async exportDatabaseDump(app: Application, onProgress?: (percent: number) => void): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const dbName = `${app.name}-db`;
const jobName = `${app.name}-db-dump-${Date.now()}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const defaultDbVer = isPostgres ? '16' : '8.0';
const dbVer = app.dbVersion || defaultDbVer;
const image = isPostgres ? `postgres:${dbVer}-alpine` : `mysql:${dbVer}`;
// Dump command writes to /dump/output.sql, then sleeps to allow exec retrieval
const command = isPostgres
? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbName} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`]
: ['sh', '-c', `mysqldump -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 900`];
// Dump command writes to spec.outputPath, then sleeps to allow exec retrieval
const { image, outputPath, dumpCommand: command } = this.databaseDumpSpec(app, dbName);
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
@@ -4519,7 +4589,7 @@ export class KubernetesService implements OnModuleInit {
});
await new Promise<void>((resolve, reject) => {
exec.exec(namespace, podName!, 'dump', ['cat', '/dump/output.sql'], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => {
exec.exec(namespace, podName!, 'dump', ['cat', outputPath], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => {
if (status.status === 'Success') resolve();
else reject(new Error(status.message || 'exec failed'));
});
@@ -4564,7 +4634,7 @@ export class KubernetesService implements OnModuleInit {
async archiveWpContent(app: Application): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-archive-${Date.now()}`;
@@ -4712,127 +4782,92 @@ export class KubernetesService implements OnModuleInit {
/**
* Restore wp-content from a tar.gz archive into the WordPress PVC.
*
* The archive is streamed into a helper pod with `kubectl cp` (a Secret
* would be capped at ~1MiB — far too small for real wp-content) and
* extracted in place onto the mounted PVC.
*/
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-restore-${Date.now()}`;
const secretName = `${jobName}-archive`;
const ts = Date.now();
const helperPodName = `${app.name}-wp-restore-${ts}`;
// Store archive in a secret
const archiveSecret = {
const helperPod: k8s.V1Pod = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: secretName, namespace },
data: { 'wp-content.tar.gz': archiveBuffer.toString('base64') },
};
try {
await coreApi.createNamespacedSecret({ namespace, body: archiveSecret });
} catch (e: any) {
return {
success: false,
logs: `Failed to create archive secret: ${e.message}`,
};
}
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
kind: 'Pod',
metadata: { name: helperPodName, namespace },
spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'restore',
image: 'alpine:3.19',
command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && rm -rf /wp-content/* && cd /wp-content && tar xzf /archive/wp-content.tar.gz && echo "RESTORE_DONE"'],
volumeMounts: [
{ name: 'wp-content', mountPath: '/wp-content' },
{ name: 'archive', mountPath: '/archive', readOnly: true },
],
resources: {
requests: { cpu: '100m', memory: '64Mi' },
limits: { cpu: '500m', memory: '256Mi' },
},
},
],
volumes: [
{
name: 'wp-content',
persistentVolumeClaim: { claimName: pvcName },
},
{ name: 'archive', secret: { secretName } },
],
containers: [
{
name: 'restore',
image: 'alpine:3.19',
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'wp-content', mountPath: '/wp-content' }],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
},
},
],
volumes: [{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } }],
restartPolicy: 'Never',
},
};
try {
await batchApi.createNamespacedJob({ namespace, body: job });
} catch (e: any) {
try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace });
} catch {}
return {
success: false,
logs: `Failed to create restore job: ${e.message}`,
};
}
const tmpArchive = path.join(os.tmpdir(), `wp-content-restore-${ts}.tar.gz`);
const tmpKubeconfig = path.join(os.tmpdir(), `kubeconfig-wprestore-${ts}.yaml`);
// Wait
const timeout = 300_000;
const start = Date.now();
let succeeded = false;
let failed = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const st = await batchApi.readNamespacedJob({
name: jobName,
namespace,
});
if (st.status?.succeeded && st.status.succeeded > 0) {
succeeded = true;
break;
}
if (st.status?.failed && st.status.failed > 0) {
failed = true;
break;
}
} catch {}
}
let logs = '';
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
if (pods.items.length > 0 && pods.items[0].metadata?.name) {
const logRes = await coreApi.readNamespacedPodLog({
name: pods.items[0].metadata.name,
namespace,
});
logs = logRes || '';
fs.writeFileSync(tmpArchive, archiveBuffer);
fs.writeFileSync(tmpKubeconfig, kc.exportConfig());
await coreApi.createNamespacedPod({ namespace, body: helperPod });
// Wait for helper pod Running
const podTimeout = 120_000;
const podStart = Date.now();
while (Date.now() - podStart < podTimeout) {
const pod = await coreApi.readNamespacedPod({ name: helperPodName, namespace });
if (pod.status?.phase === 'Running') break;
if (pod.status?.phase === 'Failed') throw new Error('wp-content restore helper pod failed to start');
await new Promise((r) => setTimeout(r, 2000));
}
} catch {}
try {
await coreApi.deleteNamespacedSecret({ name: secretName, namespace });
} catch {}
await execFileAsync(
'kubectl',
['--kubeconfig', tmpKubeconfig, 'cp', tmpArchive, `${namespace}/${helperPodName}:/tmp/wp-content.tar.gz`, '--retries', '3'],
{ maxBuffer: 50 * 1024 * 1024, timeout: 600_000 },
);
return {
success: succeeded && !failed,
logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out'),
};
const { stdout, stderr } = await execFileAsync(
'kubectl',
[
'--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '--',
'sh', '-c',
'rm -rf /wp-content/* /wp-content/.[!.]* 2>/dev/null; tar xzf /tmp/wp-content.tar.gz -C /wp-content && echo RESTORE_DONE',
],
{ maxBuffer: 10 * 1024 * 1024, timeout: 600_000 },
);
const logs = `${stdout || ''}${stderr || ''}`;
const success = logs.includes('RESTORE_DONE');
return { success, logs: logs || (success ? 'Restore completed' : 'Restore failed') };
} catch (e: any) {
this.logger.error(`wp-content restore failed for ${app.name}: ${e.message}`);
return { success: false, logs: e.message || 'wp-content restore failed' };
} finally {
try {
fs.unlinkSync(tmpArchive);
} catch {}
try {
fs.unlinkSync(tmpKubeconfig);
} catch {}
try {
await coreApi.deleteNamespacedPod({ name: helperPodName, namespace });
} catch {}
}
}
// ─── K8s Revision-based Rollback ─────────────────────
@@ -4852,7 +4887,7 @@ export class KubernetesService implements OnModuleInit {
}>;
currentRevision: number;
}> {
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name;
try {
@@ -4890,7 +4925,7 @@ export class KubernetesService implements OnModuleInit {
* Rollback a Helm release to a specific revision.
*/
async rollbackDeploymentRevision(app: Application, targetRevision: number): Promise<{ success: boolean; message: string }> {
const namespace = `user-${app.userId.split('-')[0]}`;
const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name;
try {