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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user