Files
cloud-host/backend/src/kubernetes/kubernetes.service.ts
T
keyhan 8163665c86 fix(platform): close remaining audit findings from security review
Harden preview/deploy flows, OTP generation, zip extraction, and multi-replica billing races; document full remediation status in AUDIT-STATUS.fa.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 12:30:22 +03:30

4941 lines
168 KiB
TypeScript

import { Injectable, Logger, OnModuleInit, BadRequestException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
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';
import { PassThrough } from 'stream';
import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity';
import { Deployment } from '../deployments/entities/deployment.entity';
import { ensureAppUrlEnv } from '../applications/app-url.util';
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget, ProductType, isManagedProductType } from '../common/enums';
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);
interface ManifestContext {
appName: string;
namespace: string;
image: string;
port: number;
replicas: number;
cpuRequest: string;
cpuLimit: string;
memoryRequest: string;
memoryLimit: string;
envVars: Record<string, string>;
runtime: string;
databaseType: string;
domain: string;
subdomain: string;
dbUsername: string;
dbPassword: string;
dbVersion: string;
dbStorageSize: string;
dbCpuRequest: string;
dbCpuLimit: string;
dbMemoryRequest: string;
dbMemoryLimit: string;
appStorageSize: string;
enableRedis: boolean;
redisVersion: string;
enableRabbitmq: boolean;
rabbitmqVersion: string;
enableElasticsearch: boolean;
elasticsearchVersion: string;
logPaths: string[];
ownerId: string;
applicationId: string;
}
type StorageUsageSlice = {
allocatedRaw: string;
allocatedGi: number;
usedGi: number;
availableGi: number;
usedPercent: number;
allocated: string;
used: string;
available: string;
};
@Injectable()
export class KubernetesService implements OnModuleInit {
private readonly logger = new Logger(KubernetesService.name);
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
private helmService: HelmService,
private registryService: RegistryService,
private k8sClientService: K8sClientService,
private k8sLifecycleService: K8sLifecycleService,
@InjectRepository(Deployment)
private deploymentsRepository: Repository<Deployment>,
) {}
/**
* Stable per-app preview number, reused from the latest deployment that has
* one. Used to restore the preview host on the Ingress (e.g. after a custom
* domain is removed) without triggering a fresh deploy.
*/
private async resolvePreviewNumber(applicationId: string): Promise<string | null> {
const existing = await this.deploymentsRepository
.createQueryBuilder('d')
.select('d.previewSubdomain', 'previewSubdomain')
.where('d.applicationId = :applicationId', { applicationId })
.andWhere('d.previewSubdomain IS NOT NULL')
.orderBy('d.createdAt', 'DESC')
.limit(1)
.getRawOne<{ previewSubdomain: string }>();
return existing?.previewSubdomain || null;
}
onModuleInit() {
// Helm chart is used for deployments — no local template loading needed
}
/**
* Build Helm values object from an Application entity and image URI.
*/
/**
* Resolve the database workload's CPU/RAM. Prefers the user-selected
* per-database resources (optionalServiceResources.database); falls back to
* the app's main resources for managed databases / legacy apps.
*/
private resolveDatabaseResources(app: Application) {
const res = app.optionalServiceResources?.database;
return {
cpuRequest: res?.cpuRequest || app.cpuRequest || '100m',
cpuLimit: res?.cpuLimit || app.cpuLimit || '500m',
memoryRequest: res?.memoryRequest || app.memoryRequest || '256Mi',
memoryLimit: res?.memoryLimit || app.memoryLimit || '512Mi',
};
}
private buildRedisHelmBlock(app: Application) {
const res = app.optionalServiceResources?.redis;
const storageGi = res?.storageGi ?? 1;
return {
enabled: app.enableRedis || false,
version: app.redisVersion || '7.2',
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '50m',
cpuLimit: res?.cpuLimit || '200m',
memoryRequest: res?.memoryRequest || '64Mi',
memoryLimit: res?.memoryLimit || '256Mi',
},
};
}
private buildRabbitmqHelmBlock(app: Application) {
const res = app.optionalServiceResources?.rabbitmq;
const storageGi = res?.storageGi ?? 2;
return {
enabled: app.enableRabbitmq || false,
version: app.rabbitmqVersion || '3.13',
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '100m',
cpuLimit: res?.cpuLimit || '500m',
memoryRequest: res?.memoryRequest || '256Mi',
memoryLimit: res?.memoryLimit || '512Mi',
},
};
}
private resolveEnvVars(app: Application): Record<string, string> {
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
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 };
}
/** Create platform StorageClass on the target cluster when configured (Helm chart + K8s API fallback). */
private async ensurePlatformStorageClass(kubeconfig: string): Promise<void> {
const storageClass = this.configService.get<string>('platform.storageClass')?.trim();
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
if (!storageClass || !createStorageClass) {
return;
}
const kc = new k8s.KubeConfig();
kc.loadFromString(kubeconfig);
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
const provisioner = this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
try {
await storageApi.readStorageClass({ name: storageClass });
return;
} catch (err: any) {
if (err.code !== 404 && err.body?.code !== 404) {
throw err;
}
}
await storageApi.createStorageClass({
body: {
apiVersion: 'storage.k8s.io/v1',
kind: 'StorageClass',
metadata: { name: storageClass },
provisioner,
allowVolumeExpansion: true,
reclaimPolicy: 'Delete',
volumeBindingMode: 'WaitForFirstConsumer',
},
});
this.logger.log(`Created StorageClass "${storageClass}" (provisioner: ${provisioner})`);
}
/** 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 = this.getUserNamespace(app.userId);
const pullRegistryUrl = this.registryService.getRegistryUrl();
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.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app),
},
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,
},
images: { baseRegistry: this.configService.get<string>('build.baseImageRegistry') || '' },
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, previewNumber?: string | null): Record<string, any> {
const domain = this.configService.get('platform.domain');
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = userIdSlug(app.userId);
const previewHost = previewNumber && !this.hasVerifiedCustomDomain(app)
? `${namespacePrefix}-${previewNumber}.${previewRootDomain}`
: '';
const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
const hasDb = app.databaseType !== DatabaseType.NONE;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const values: Record<string, any> = {
global: this.helmGlobalStorageValues(),
app: {
enabled: true,
name: app.name,
namespace: this.getUserNamespace(app.userId),
runtime: app.runtime,
image: imageUri,
port: app.port,
replicas: app.replicas || 1,
storageSize: app.appStorageSize || '2Gi',
},
resources: {
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
},
envVars: this.resolveEnvVars(app),
ingress: {
enabled: true,
className: this.configService.get<string>('platform.ingressClass') || 'traefik',
subdomain: app.subdomain || app.name,
domain: domain,
clusterIssuer: 'letsencrypt-prod',
customDomain: app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '',
previewHost,
},
registry: {
url: pullRegistryUrl,
},
database: {
enabled: hasDb,
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: hasDb ? this.ensureDbPassword(app) : '',
storageSize: app.dbStorageSize || '1Gi',
resources: this.resolveDatabaseResources(app),
},
wordpress: {
enabled: isWordPress,
},
redis: this.buildRedisHelmBlock(app),
rabbitmq: this.buildRabbitmqHelmBlock(app),
elasticsearch: {
enabled: app.enableElasticsearch || false,
logPaths: app.logPaths || [],
ownerId: app.userId,
applicationId: app.id,
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()}`,
};
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, opts?: { previewNumber?: string | null }): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) {
return this.deployManagedService(app);
}
const previewNumber = opts?.previewNumber ?? null;
const workloadImage = this.registryService.normalizeImageReference(imageUri);
if (workloadImage !== imageUri) {
this.logger.log(`Using in-cluster registry image for ${app.name}: ${workloadImage}`);
}
// Try Helm first, fall back to direct K8s API if Helm is unavailable
try {
return await this.deployViaHelm(app, workloadImage, previewNumber);
} catch (helmError: any) {
this.logger.warn(`Helm deploy failed for ${app.name}, falling back to direct K8s API: ${helmError.message}`);
return await this.deployViaK8sApi(app, workloadImage, previewNumber);
}
}
async waitForApplicationReady(app: Application, timeoutMs = 600_000, shouldAbort?: () => Promise<boolean>): Promise<void> {
const { coreApi, appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const managed = isManagedProductType(app.productType);
const workloads = [
...(!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 }] : []),
];
const start = Date.now();
let lastSummary = '';
this.logger.log(`Waiting for ${app.name} workloads to become Ready in ${namespace}: ${workloads.map((w) => w.name).join(', ')}`);
while (Date.now() - start < timeoutMs) {
if (await shouldAbort?.()) {
throw new Error('Deployment cancelled by user');
}
const statuses = await Promise.all(workloads.map((workload) => this.getDeploymentReadiness(appsApi, namespace, workload.name, workload.replicas)));
lastSummary = statuses.map((s) => `${s.name} ${s.readyReplicas}/${s.desiredReplicas}`).join(', ');
if (statuses.every((s) => s.ready)) {
this.logger.log(`All workloads for ${app.name} are Ready (${lastSummary})`);
return;
}
await new Promise((resolve) => setTimeout(resolve, 5000));
}
const podSummary = await this.describeWorkloadPods(
coreApi,
namespace,
workloads.map((w) => w.name),
);
throw new Error(`Application workloads did not become ready within ${Math.round(timeoutMs / 1000)}s. ` + `Readiness: ${lastSummary || 'no deployment status available'}. ${podSummary}`);
}
async updateIngress(app: Application): Promise<void> {
const domain = this.configService.get('platform.domain');
const subdomain = app.subdomain || app.name;
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
// the app stays reachable (and keeps its TLS) — e.g. after a custom domain is
// removed. buildHelmValues only emits a preview host when there's no
// app.customDomain at all, so we only resolve it when none is set.
const previewNumber = app.customDomain ? null : await this.resolvePreviewNumber(app.id);
try {
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
const imageUri = app.latestImageTag ? this.registryService.normalizeImageReference(app.latestImageTag) : '';
const values = this.buildHelmValues(app, imageUri, previewNumber);
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
} catch (helmError: any) {
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
const { networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const ctx: ManifestContext = {
appName: app.name,
namespace,
image: '',
port: app.port,
replicas: app.replicas,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: this.resolveEnvVars(app),
runtime: app.runtime,
databaseType: app.databaseType,
domain,
subdomain,
dbUsername: app.dbUsername || '',
dbPassword: app.dbPassword || '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit,
dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest,
dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit,
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: app.enableRedis || false,
redisVersion: app.redisVersion || '7.2',
enableRabbitmq: app.enableRabbitmq || false,
rabbitmqVersion: app.rabbitmqVersion || '3.13',
enableElasticsearch: app.enableElasticsearch || false,
elasticsearchVersion: app.elasticsearchVersion || '8.12',
logPaths: app.logPaths || [],
ownerId: app.userId,
applicationId: app.id,
};
await this.applyIngress(networkingApi, ctx, customDomain, previewNumber);
this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`);
}
}
// ── Helm-based deployment ─────────────────────────────────────────
private async deployViaHelm(app: Application, imageUri: string, previewNumber?: string | null): Promise<Record<string, any>> {
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const values = this.buildHelmValues(app, imageUri, previewNumber);
const namespace = values.app.namespace as string;
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
const releaseName = app.name;
const result = await this.helmService.installOrUpgrade(releaseName, namespace, values, kubeconfig);
this.logger.log(`Successfully deployed ${app.name} to namespace ${namespace} via Helm`);
return {
helm: { release: releaseName, namespace, stdout: result.stdout },
values,
};
}
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
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.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const namespace = this.getUserNamespace(app.userId);
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.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit,
dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest,
dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit,
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, previewNumber?: string | null): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) {
return this.deployManagedViaK8sApi(app);
}
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig);
const domain = this.configService.get('platform.domain');
const context: ManifestContext = {
appName: app.name,
namespace: this.getUserNamespace(app.userId),
image: imageUri,
port: app.port,
replicas: app.replicas,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: this.resolveEnvVars(app),
runtime: app.runtime,
databaseType: app.databaseType,
domain: domain,
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword:
app.databaseType && app.databaseType !== DatabaseType.NONE
? this.ensureDbPassword(app)
: '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit,
dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest,
dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit,
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: app.enableRedis || false,
redisVersion: app.redisVersion || '7.2',
enableRabbitmq: app.enableRabbitmq || false,
rabbitmqVersion: app.rabbitmqVersion || '3.13',
enableElasticsearch: app.enableElasticsearch || false,
elasticsearchVersion: app.elasticsearchVersion || '8.12',
logPaths: app.logPaths || [],
ownerId: app.userId,
applicationId: app.id,
};
const manifests: Record<string, any> = {};
try {
// 1. Ensure namespace exists
await this.ensureNamespace(coreApi, context.namespace);
// 1b. Image pull secret for in-cluster registry
await this.registryService.ensureRegistryPullSecret(coreApi, context.namespace);
// 2. Create/Update secrets for env vars
if (Object.keys(context.envVars).length > 0) {
manifests.secret = await this.applySecret(coreApi, context);
}
// 3. Deploy database if needed
if (context.databaseType !== DatabaseType.NONE) {
manifests.database = await this.deployDatabase(coreApi, appsApi, context);
}
// 3.5 Deploy optional services
if (context.enableRedis) {
await this.deployRedis(coreApi, appsApi, context);
manifests.redis = true;
}
if (context.enableRabbitmq) {
await this.deployRabbitmq(coreApi, appsApi, context);
manifests.rabbitmq = true;
}
// 3.7 Logging: credentials secret + Fluent Bit config
if (context.enableElasticsearch) {
await this.ensureElasticsearchCredentialsSecret(coreApi, context.namespace);
await this.createFluentBitConfigMap(coreApi, context);
manifests.fluentBitConfig = true;
}
// 3.8 Create app storage PVC (for all app types)
manifests.appStoragePvc = await this.applyAppStoragePvc(coreApi, context);
// 4. Create Deployment
manifests.deployment = await this.applyDeployment(appsApi, context);
// 5. Create Service
manifests.service = await this.applyService(coreApi, context);
// 6. Create Ingress
const customDomain = app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : undefined;
manifests.ingress = await this.applyIngress(networkingApi, context, customDomain, previewNumber);
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`);
} catch (error: any) {
this.logger.error(`Failed to deploy ${app.name} via K8s API:`, error.body || error.message);
throw error;
}
return manifests;
}
// ── Private K8s resource methods ──────────────────────────────────
private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
try {
await coreApi.readNamespace({ name: namespace });
} catch {
await coreApi.createNamespace({
body: { metadata: { name: namespace } },
});
this.logger.log(`Created namespace: ${namespace}`);
}
}
private async applySecret(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
const secretData: Record<string, string> = {};
for (const [key, value] of Object.entries(ctx.envVars)) {
secretData[key] = Buffer.from(String(value)).toString('base64');
}
const secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: {
name: `${ctx.appName}-env`,
namespace: ctx.namespace,
},
data: secretData,
};
try {
await coreApi.replaceNamespacedSecret({
name: `${ctx.appName}-env`,
namespace: ctx.namespace,
body: secret,
});
} catch {
await coreApi.createNamespacedSecret({
namespace: ctx.namespace,
body: secret,
});
}
return secret;
}
private async applyDeployment(appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<any> {
const envFrom: any[] = [];
if (Object.keys(ctx.envVars).length > 0) {
envFrom.push({ secretRef: { name: `${ctx.appName}-env` } });
}
// Database connection env vars
const extraEnv: any[] = [];
if (ctx.databaseType === DatabaseType.POSTGRESQL) {
extraEnv.push(
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
{ name: 'DB_PORT', value: '5432' },
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
{
name: 'DB_USER',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' },
},
},
{
name: 'DB_PASSWORD',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' },
},
},
{
name: 'DATABASE_URL',
value: `postgresql://$(DB_USER):$(DB_PASSWORD)@${ctx.appName}-db:5432/${ctx.appName.replace(/-/g, '_')}`,
},
);
} else if (ctx.databaseType === DatabaseType.MYSQL || ctx.databaseType === DatabaseType.MARIADB) {
extraEnv.push(
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
{ name: 'DB_PORT', value: '3306' },
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
{
name: 'DB_USER',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' },
},
},
{
name: 'DB_PASSWORD',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' },
},
},
{
name: 'DATABASE_URL',
value: `mysql://$(DB_USER):$(DB_PASSWORD)@${ctx.appName}-db:3306/${ctx.appName.replace(/-/g, '_')}`,
},
);
} else if (ctx.databaseType === DatabaseType.MONGODB) {
extraEnv.push(
{ name: 'DB_HOST', value: `${ctx.appName}-db` },
{ name: 'DB_PORT', value: '27017' },
{ name: 'DB_NAME', value: ctx.appName.replace(/-/g, '_') },
{
name: 'DB_USER',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' },
},
},
{
name: 'DB_PASSWORD',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' },
},
},
{
name: 'MONGODB_URI',
value: `mongodb://$(DB_USER):$(DB_PASSWORD)@${ctx.appName}-db:27017/${ctx.appName.replace(/-/g, '_')}?authSource=admin`,
},
{
name: 'DATABASE_URL',
value: `mongodb://$(DB_USER):$(DB_PASSWORD)@${ctx.appName}-db:27017/${ctx.appName.replace(/-/g, '_')}?authSource=admin`,
},
);
}
if ((ctx.runtime === AppRuntime.LARAVEL || ctx.runtime === AppRuntime.PHP) && ctx.databaseType !== DatabaseType.NONE) {
const dbConnection = ctx.databaseType === DatabaseType.POSTGRESQL ? 'pgsql' : ctx.databaseType === DatabaseType.MONGODB ? 'mongodb' : 'mysql';
extraEnv.push(
{ name: 'DB_CONNECTION', value: dbConnection },
{ name: 'DB_DATABASE', value: ctx.appName.replace(/-/g, '_') },
{
name: 'DB_USERNAME',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' },
},
},
);
}
// Redis connection env vars
if (ctx.enableRedis) {
const redisName = `${ctx.appName}-redis`;
extraEnv.push(
{ name: 'REDIS_HOST', value: redisName },
{ name: 'REDIS_PORT', value: '6379' },
{
name: 'REDIS_PASSWORD',
valueFrom: {
secretKeyRef: { name: `${redisName}-secret`, key: 'password' },
},
},
{
name: 'REDIS_URL',
value: `redis://:$(REDIS_PASSWORD)@${redisName}:6379`,
},
);
}
// RabbitMQ connection env vars
if (ctx.enableRabbitmq) {
const rabbitName = `${ctx.appName}-rabbitmq`;
extraEnv.push(
{ name: 'RABBITMQ_HOST', value: rabbitName },
{ name: 'RABBITMQ_PORT', value: '5672' },
{ name: 'RABBITMQ_MANAGEMENT_PORT', value: '15672' },
{
name: 'RABBITMQ_USER',
valueFrom: {
secretKeyRef: { name: `${rabbitName}-secret`, key: 'username' },
},
},
{
name: 'RABBITMQ_PASSWORD',
valueFrom: {
secretKeyRef: { name: `${rabbitName}-secret`, key: 'password' },
},
},
{
name: 'AMQP_URL',
value: `amqp://$(RABBITMQ_USER):$(RABBITMQ_PASSWORD)@${rabbitName}:5672`,
},
);
}
// WordPress-specific env vars
if (ctx.runtime === AppRuntime.WORDPRESS && ctx.databaseType !== DatabaseType.NONE) {
const dbPort = ctx.databaseType === DatabaseType.POSTGRESQL ? '5432' : '3306';
extraEnv.push(
{ name: 'WORDPRESS_DB_HOST', value: `${ctx.appName}-db:${dbPort}` },
{ name: 'WORDPRESS_DB_NAME', value: ctx.appName.replace(/-/g, '_') },
{
name: 'WORDPRESS_DB_USER',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' },
},
},
{
name: 'WORDPRESS_DB_PASSWORD',
valueFrom: {
secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' },
},
},
{ name: 'WORDPRESS_TABLE_PREFIX', value: 'wp_' },
);
}
const deployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: ctx.appName,
namespace: ctx.namespace,
labels: { app: ctx.appName, runtime: ctx.runtime },
annotations: {
'kubernetes.io/change-cause': `Deploy ${ctx.image} at ${new Date().toISOString()}`,
},
},
spec: {
revisionHistoryLimit: 10,
replicas: ctx.replicas,
selector: { matchLabels: { app: ctx.appName } },
template: {
metadata: { labels: { app: ctx.appName, runtime: ctx.runtime } },
spec: {
imagePullSecrets: [{ name: 'registry-pull-secret' }],
containers: this.buildContainersSpec(ctx, envFrom, extraEnv),
volumes: this.buildVolumesSpec(ctx),
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment({
name: ctx.appName,
namespace: ctx.namespace,
body: deployment,
});
} catch {
await appsApi.createNamespacedDeployment({
namespace: ctx.namespace,
body: deployment,
});
}
return deployment;
}
private async applyAppStoragePvc(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
const pvcName = `${ctx.appName}-storage`;
const pvc = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: {
name: pvcName,
namespace: ctx.namespace,
labels: { app: ctx.appName, runtime: ctx.runtime },
},
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: ctx.appStorageSize || '2Gi' } },
...(this.configService.get<string>('platform.storageClass')
? {
storageClassName: this.configService.get<string>('platform.storageClass'),
}
: {}),
},
};
try {
await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace: ctx.namespace,
});
this.logger.log(`PVC ${pvcName} already exists, skipping`);
} catch {
await coreApi.createNamespacedPersistentVolumeClaim({
namespace: ctx.namespace,
body: pvc,
});
this.logger.log(`Created app storage PVC: ${pvcName}`);
}
return pvc;
}
/**
* Get the storage mount path based on runtime type
*/
private getStorageMountPath(runtime: string): string {
switch (runtime) {
case AppRuntime.WORDPRESS:
return '/var/www/html/wp-content';
case AppRuntime.LARAVEL:
case AppRuntime.PHP:
return '/var/www/html/storage';
case AppRuntime.DJANGO:
return '/app/media';
case AppRuntime.PYTHON:
case AppRuntime.GO:
case AppRuntime.DOTNET:
case AppRuntime.NODEJS:
default:
return '/app/data';
}
}
/**
* Build containers spec for the app deployment, including optional Fluent Bit sidecar
*/
private buildContainersSpec(ctx: ManifestContext, envFrom: any[], extraEnv: any[]): any[] {
const containers: any[] = [];
// Main application container
const appContainer: any = {
name: ctx.appName,
// Image tags are unique per build (name:timestamp) and immutable, so
// IfNotPresent is correct and avoids re-pulling on every restart/scale-up.
image: ctx.image,
imagePullPolicy: 'IfNotPresent',
ports: [{ containerPort: ctx.port }],
envFrom,
env: extraEnv,
resources: {
requests: { cpu: ctx.cpuRequest, memory: ctx.memoryRequest },
limits: { cpu: ctx.cpuLimit, memory: ctx.memoryLimit },
},
readinessProbe: {
tcpSocket: { port: ctx.port as any },
initialDelaySeconds: 5,
periodSeconds: 5,
},
livenessProbe: {
tcpSocket: { port: ctx.port as any },
initialDelaySeconds: 15,
periodSeconds: 10,
failureThreshold: 5,
},
volumeMounts: [
{
name: 'app-storage',
mountPath: this.getStorageMountPath(ctx.runtime),
},
],
};
// Add log volume mount if Elasticsearch is enabled
if (ctx.enableElasticsearch) {
appContainer.volumeMounts.push({
name: 'app-logs',
mountPath: '/var/log/app',
});
this.applyLoggingCommandWrapper(appContainer, ctx.runtime);
}
containers.push(appContainer);
// Add Fluent Bit sidecar for log collection if Elasticsearch is enabled
if (ctx.enableElasticsearch) {
containers.push({
name: 'fluent-bit',
image: 'fluent/fluent-bit:2.2',
resources: {
requests: { cpu: '10m', memory: '32Mi' },
limits: { cpu: '50m', memory: '64Mi' },
},
volumeMounts: [
{ name: 'app-logs', mountPath: '/var/log/app', readOnly: true },
{ name: 'fluent-bit-config', mountPath: '/fluent-bit/etc' },
],
env: [
{ name: 'APP_NAME', value: ctx.appName },
{ name: 'APP_NAMESPACE', value: ctx.namespace },
{ name: 'ES_HOST', value: 'elasticsearch.logging.svc.cluster.local' },
{ name: 'ES_PORT', value: '9200' },
{
name: 'ES_PASSWORD',
valueFrom: {
secretKeyRef: {
name: 'elasticsearch-credentials',
key: 'ELASTIC_PASSWORD',
optional: true,
},
},
},
],
});
}
return containers;
}
/**
* Build volumes spec for the app deployment
*/
private buildVolumesSpec(ctx: ManifestContext): any[] {
const volumes: any[] = [
{
name: 'app-storage',
persistentVolumeClaim: { claimName: `${ctx.appName}-storage` },
},
];
if (ctx.enableElasticsearch) {
// Shared log volume between app and fluent-bit
volumes.push({
name: 'app-logs',
emptyDir: {},
});
// Fluent Bit config as ConfigMap
volumes.push({
name: 'fluent-bit-config',
configMap: { name: `${ctx.appName}-fluent-bit-config` },
});
}
return volumes;
}
/**
* Get default log paths based on runtime type
*/
private getDefaultLogPaths(runtime: string): string[] {
switch (runtime) {
case AppRuntime.WORDPRESS:
return ['/var/www/html/wp-content/debug.log', '/var/log/app/*.log'];
case AppRuntime.LARAVEL:
return ['/var/www/html/storage/logs/*.log', '/var/log/app/*.log'];
case AppRuntime.PHP:
return ['/var/www/html/storage/logs/*.log', '/var/log/php/*.log', '/var/log/app/*.log'];
default:
// Node/Go/Python/.NET log to stdout — captured into /var/log/app/app.log at runtime
return ['/var/log/app/*.log'];
}
}
/**
* Redirect stdout/stderr into the shared log volume so Fluent Bit can tail them.
*/
private applyLoggingCommandWrapper(container: any, runtime: string): void {
const startCmd = this.getRuntimeStartCommand(runtime);
if (!startCmd) return;
container.command = ['sh', '-c'];
container.args = [`mkdir -p /var/log/app && (${startCmd}) >> /var/log/app/app.log 2>&1`];
}
/** Shell command that mirrors CloudHost-generated image ENTRYPOINT/CMD per runtime. */
private getRuntimeStartCommand(runtime: string): string | null {
switch (runtime) {
case AppRuntime.NODEJS:
return 'if [ -f /app/.mode ] && [ "$(cat /app/.mode)" = "standalone" ] && [ -f server.js ]; ' + 'then node server.js; else npm start; fi';
case AppRuntime.GO:
return './main';
case AppRuntime.PYTHON:
return (
'if [ -f main.py ]; then ' +
'if grep -qi fastapi main.py; then exec uvicorn main:app --host 0.0.0.0 --port ${PORT:-3000}; ' +
'elif grep -qi flask main.py; then exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} main:app; ' +
'else exec python main.py; fi; ' +
'elif [ -f app.py ]; then ' +
'if grep -qi fastapi app.py; then exec uvicorn app:app --host 0.0.0.0 --port ${PORT:-3000}; ' +
'elif grep -qi flask app.py; then exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} app:app; ' +
'else exec python app.py; fi; ' +
'else exec gunicorn -w 4 -b 0.0.0.0:${PORT:-3000} app:app; fi'
);
case AppRuntime.DJANGO:
return 'python manage.py runserver 0.0.0.0:${PORT:-8000}';
case AppRuntime.DOTNET:
return 'DLL=$(find . -maxdepth 1 -name "*.dll" ! -name "*.deps.dll" ! -name "*.runtimeconfig.dll" | head -1) ' + '&& dotnet "$DLL"';
case AppRuntime.WORDPRESS:
case AppRuntime.LARAVEL:
case AppRuntime.PHP:
return null;
default:
return null;
}
}
/** 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: { [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 {
await coreApi.readNamespacedSecret({ name, namespace });
await coreApi.replaceNamespacedSecret({
name,
namespace,
body: {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name, namespace },
type: 'Opaque',
stringData,
},
});
} catch (err: any) {
if (err.code === 404 || err.body?.code === 404) {
await coreApi.createNamespacedSecret({
namespace,
body: {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name, namespace },
type: 'Opaque',
stringData,
},
});
this.logger.log(`Created ${name} secret in ${namespace}`);
} else {
throw err;
}
}
}
/**
* Build Fluent Bit configuration for log collection
*/
private buildFluentBitConfig(appName: string, namespace: string, runtime: string, ownerId: string, applicationId: string, workload: string, customLogPaths?: string[]): string {
const logPaths = customLogPaths && customLogPaths.length > 0 ? customLogPaths : this.getDefaultLogPaths(runtime);
const pathsStr = logPaths.join(',');
return `
[SERVICE]
Flush 5
Daemon Off
Log_Level info
Parsers_File /fluent-bit/etc/parsers.conf
[INPUT]
Name tail
Path ${pathsStr}
Tag app.${appName}
Refresh_Interval 5
Mem_Buf_Limit 5MB
Skip_Long_Lines On
[FILTER]
Name record_modifier
Match *
Record app ${appName}
Record applicationName ${appName}
Record namespace ${namespace}
Record runtime ${runtime}
Record ownerId ${ownerId}
Record applicationId ${applicationId}
Record workload ${workload}
[FILTER]
Name parser
Match *
Key_Name log
Parser json
Reserve_Data On
Preserve_Key On
[OUTPUT]
Name es
Match *
Host \${ES_HOST}
Port \${ES_PORT}
HTTP_User elastic
HTTP_Passwd \${ES_PASSWORD}
Index logs-${namespace}-${appName}
Logstash_Format On
Logstash_Prefix logs-${namespace}
Suppress_Type_Name On
tls Off
Retry_Limit 3
`;
}
/**
* Create Fluent Bit ConfigMap for an app
*/
private async createFluentBitConfigMap(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<void> {
if (!ctx.enableElasticsearch) return;
const customLogPaths = ctx.logPaths && ctx.logPaths.length > 0 ? ctx.logPaths : undefined;
const configMap = {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: `${ctx.appName}-fluent-bit-config`,
namespace: ctx.namespace,
labels: { app: ctx.appName },
},
data: {
'fluent-bit.conf': this.buildFluentBitConfig(ctx.appName, ctx.namespace, ctx.runtime, ctx.ownerId, ctx.applicationId, 'app', customLogPaths),
'parsers.conf': `
[PARSER]
Name json
Format json
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%L
[PARSER]
Name docker
Format json
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%L
`,
},
};
try {
await coreApi.replaceNamespacedConfigMap({
name: `${ctx.appName}-fluent-bit-config`,
namespace: ctx.namespace,
body: configMap,
});
} catch {
await coreApi.createNamespacedConfigMap({
namespace: ctx.namespace,
body: configMap,
});
}
this.logger.log(`Created Fluent Bit ConfigMap for ${ctx.appName}`);
}
private buildWorkloadFluentBitConfig(ctx: ManifestContext, workload: 'redis' | 'rabbitmq' | 'database', resourceName: string): string {
const logGlob = `/var/log/pods/*${resourceName}*/*/*.log`;
return `
[SERVICE]
Flush 5
Daemon Off
Log_Level info
Parsers_File /fluent-bit/etc/parsers.conf
[INPUT]
Name tail
Path ${logGlob}
Tag ${workload}.${resourceName}
Refresh_Interval 5
Mem_Buf_Limit 5MB
Skip_Long_Lines On
Parser docker
[FILTER]
Name record_modifier
Match *
Record app ${ctx.appName}
Record applicationName ${ctx.appName}
Record namespace ${ctx.namespace}
Record ownerId ${ctx.ownerId}
Record applicationId ${ctx.applicationId}
Record workload ${workload}
[OUTPUT]
Name es
Match *
Host \${ES_HOST}
Port \${ES_PORT}
HTTP_User elastic
HTTP_Passwd \${ES_PASSWORD}
Index logs-${ctx.namespace}-${ctx.appName}
Logstash_Format On
Logstash_Prefix logs-${ctx.namespace}
Suppress_Type_Name On
tls Off
Retry_Limit 3
`;
}
private async attachWorkloadLogShipper(
coreApi: k8s.CoreV1Api,
ctx: ManifestContext,
workload: 'redis' | 'rabbitmq' | 'database',
resourceName: string,
): Promise<{ containers: k8s.V1Container[]; volumes: k8s.V1Volume[] }> {
if (!ctx.enableElasticsearch) {
return { containers: [], volumes: [] };
}
const configMapName = `${resourceName}-log-shipper-config`;
const configMap = {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: configMapName,
namespace: ctx.namespace,
labels: { app: resourceName, 'cloudhost.io/log-shipper': 'true' },
},
data: {
'fluent-bit.conf': this.buildWorkloadFluentBitConfig(ctx, workload, resourceName),
'parsers.conf': `
[PARSER]
Name docker
Format json
Time_Key time
Time_Format %Y-%m-%dT%H:%M:%S.%L
`,
},
};
try {
await coreApi.replaceNamespacedConfigMap({
name: configMapName,
namespace: ctx.namespace,
body: configMap,
});
} catch {
await coreApi.createNamespacedConfigMap({
namespace: ctx.namespace,
body: configMap,
});
}
return {
containers: [
{
name: 'log-shipper',
image: 'fluent/fluent-bit:2.2',
resources: {
requests: { cpu: '10m', memory: '32Mi' },
limits: { cpu: '50m', memory: '64Mi' },
},
volumeMounts: [
{ name: 'varlogpods', mountPath: '/var/log/pods', readOnly: true },
{ name: 'log-shipper-config', mountPath: '/fluent-bit/etc' },
],
env: [
{
name: 'ES_HOST',
value: 'elasticsearch.logging.svc.cluster.local',
},
{ name: 'ES_PORT', value: '9200' },
{
name: 'ES_PASSWORD',
valueFrom: {
secretKeyRef: {
name: 'elasticsearch-credentials',
key: 'ELASTIC_PASSWORD',
optional: true,
},
},
},
],
},
],
volumes: [
{
name: 'varlogpods',
hostPath: { path: '/var/log/pods', type: 'Directory' },
},
{ name: 'log-shipper-config', configMap: { name: configMapName } },
],
};
}
private async applyService(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
const service: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: ctx.appName, namespace: ctx.namespace },
spec: {
selector: { app: ctx.appName },
ports: [{ port: 80, targetPort: ctx.port as any, protocol: 'TCP' }],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService({
name: ctx.appName,
namespace: ctx.namespace,
body: service,
});
} catch {
await coreApi.createNamespacedService({
namespace: ctx.namespace,
body: service,
});
}
return service;
}
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext, customDomain?: string, previewNumber?: string | null): Promise<any> {
const host = `${ctx.subdomain}.${ctx.domain}`;
const rules: k8s.V1IngressRule[] = [
{
host,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
},
];
// Only PUBLIC, real-TLD hosts may go into the TLS block. The internal
// `${subdomain}.${domain}` host (e.g. *.apps.cloudhost.local) is not a valid
// public suffix — including it makes Let's Encrypt reject the whole order,
// which would also block the cert for the legitimate preview/custom domains.
const tlsHosts: string[] = [];
if (customDomain) {
rules.push({
host: customDomain,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
});
tlsHosts.push(customDomain);
}
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || ctx.domain;
const namespacePrefix = userIdSlug(ctx.ownerId);
const previewHost = previewNumber && !customDomain ? `${namespacePrefix}-${previewNumber}.${previewRootDomain}` : '';
if (previewHost) {
rules.push({
host: previewHost,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
});
tlsHosts.push(previewHost);
}
const ingressClass = this.configService.get<string>('platform.ingressClass') || 'traefik';
// Request a managed cert only when we actually have a public host to issue for.
const annotations: Record<string, string> = {};
const tls: k8s.V1IngressTLS[] = [];
if (tlsHosts.length > 0) {
annotations['cert-manager.io/cluster-issuer'] = 'letsencrypt-prod';
tls.push({ hosts: tlsHosts, secretName: `${ctx.appName}-tls` });
}
const ingress: k8s.V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: ctx.appName,
namespace: ctx.namespace,
annotations,
},
spec: {
ingressClassName: ingressClass,
rules,
...(tls.length > 0 ? { tls } : {}),
},
};
try {
await networkingApi.replaceNamespacedIngress({
name: ctx.appName,
namespace: ctx.namespace,
body: ingress,
});
} catch {
await networkingApi.createNamespacedIngress({
namespace: ctx.namespace,
body: ingress,
});
}
return ingress;
}
private async deployDatabase(coreApi: k8s.CoreV1Api, appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<any> {
const dbName = `${ctx.appName}-db`;
// Create DB secret
await this.createDbSecret(coreApi, ctx.namespace, ctx.appName, ctx.dbPassword, ctx.dbUsername);
// Create PVC for DB
await this.createPVC(coreApi, ctx.namespace, dbName, ctx.dbStorageSize);
// Deploy database based on type
const dbType = ctx.databaseType;
let image: string;
let port: number;
let dataPath: string;
let envVars: any[];
let readinessProbe: any;
let livenessProbe: any;
switch (dbType) {
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: 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',
},
},
},
];
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';
image = `mysql:${mysqlVer}`;
port = 3306;
dataPath = '/var/lib/mysql';
envVars = [
{ name: 'MYSQL_DATABASE', value: ctx.appName.replace(/-/g, '_') },
{
name: 'MYSQL_USER',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'username',
},
},
},
{
name: 'MYSQL_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'password',
},
},
},
{
name: 'MYSQL_ROOT_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'password',
},
},
},
];
readinessProbe = {
exec: { command: ['mysqladmin', 'ping', '-h', '127.0.0.1'] },
initialDelaySeconds: 10,
periodSeconds: 5,
failureThreshold: 6,
};
livenessProbe = {
exec: { command: ['mysqladmin', 'ping', '-h', '127.0.0.1'] },
initialDelaySeconds: 30,
periodSeconds: 10,
failureThreshold: 5,
};
break;
case DatabaseType.MARIADB:
const mariaVer = ctx.dbVersion || '11.4';
image = `mariadb:${mariaVer}`;
port = 3306;
dataPath = '/var/lib/mysql';
envVars = [
{ name: 'MARIADB_DATABASE', value: ctx.appName.replace(/-/g, '_') },
{
name: 'MARIADB_USER',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'username',
},
},
},
{
name: 'MARIADB_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'password',
},
},
},
{
name: 'MARIADB_ROOT_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'password',
},
},
},
];
readinessProbe = {
exec: {
command: ['healthcheck.sh', '--connect', '--innodb_initialized'],
},
initialDelaySeconds: 10,
periodSeconds: 5,
failureThreshold: 6,
};
livenessProbe = {
exec: {
command: ['healthcheck.sh', '--connect', '--innodb_initialized'],
},
initialDelaySeconds: 30,
periodSeconds: 10,
failureThreshold: 5,
};
break;
case DatabaseType.MONGODB:
const mongoVer = ctx.dbVersion || '7.0';
image = `mongo:${mongoVer}`;
port = 27017;
dataPath = '/data/db';
envVars = [
{
name: 'MONGO_INITDB_DATABASE',
value: ctx.appName.replace(/-/g, '_'),
},
{
name: 'MONGO_INITDB_ROOT_USERNAME',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'username',
},
},
},
{
name: 'MONGO_INITDB_ROOT_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${ctx.appName}-db-secret`,
key: 'password',
},
},
},
];
readinessProbe = {
exec: { command: ['mongosh', '--eval', 'db.adminCommand("ping")'] },
initialDelaySeconds: 10,
periodSeconds: 5,
failureThreshold: 6,
};
livenessProbe = {
exec: { command: ['mongosh', '--eval', 'db.adminCommand("ping")'] },
initialDelaySeconds: 30,
periodSeconds: 10,
failureThreshold: 5,
};
break;
default:
throw new Error(`Unsupported database type: ${dbType}`);
}
const dbLogShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'database', dbName);
const dbDeployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: dbName,
namespace: ctx.namespace,
labels: { app: dbName },
},
spec: {
replicas: 1,
selector: { matchLabels: { app: dbName } },
template: {
metadata: { labels: { app: dbName } },
spec: {
containers: [
{
name: dbName,
image,
ports: [{ containerPort: port }],
env: envVars,
volumeMounts: [{ name: 'db-storage', mountPath: dataPath }],
resources: {
requests: { cpu: ctx.dbCpuRequest, memory: ctx.dbMemoryRequest },
limits: { cpu: ctx.dbCpuLimit, memory: ctx.dbMemoryLimit },
},
readinessProbe,
livenessProbe,
},
...dbLogShipper.containers,
],
volumes: [
{
name: 'db-storage',
persistentVolumeClaim: { claimName: dbName },
},
...dbLogShipper.volumes,
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment({
name: dbName,
namespace: ctx.namespace,
body: dbDeployment,
});
} catch {
await appsApi.createNamespacedDeployment({
namespace: ctx.namespace,
body: dbDeployment,
});
}
// Create DB Service
const dbService: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: dbName, namespace: ctx.namespace },
spec: {
selector: { app: dbName },
ports: [{ port, targetPort: port as any, protocol: 'TCP' }],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService({
name: dbName,
namespace: ctx.namespace,
body: dbService,
});
} catch {
await coreApi.createNamespacedService({
namespace: ctx.namespace,
body: dbService,
});
}
return { deployment: dbDeployment, service: dbService };
}
private async createDbSecret(coreApi: k8s.CoreV1Api, namespace: string, appName: string, password: string, username: string = 'appuser'): Promise<void> {
const secret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: `${appName}-db-secret`, namespace },
data: {
username: Buffer.from(username).toString('base64'),
password: Buffer.from(password).toString('base64'),
},
};
try {
await coreApi.replaceNamespacedSecret({
name: `${appName}-db-secret`,
namespace,
body: secret,
});
} catch {
await coreApi.createNamespacedSecret({ namespace, body: secret });
}
}
private async createPVC(coreApi: k8s.CoreV1Api, namespace: string, name: string, size: string): Promise<void> {
const storageClass = this.configService.get<string>('platform.storageClass');
const pvc: k8s.V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: size } },
...(storageClass ? { storageClassName: storageClass } : {}),
},
};
try {
await coreApi.readNamespacedPersistentVolumeClaim({ name, namespace });
// PVC exists, don't recreate
} catch {
await coreApi.createNamespacedPersistentVolumeClaim({
namespace,
body: pvc,
});
}
}
/**
* Deploy Redis for an application
*/
private async deployRedis(coreApi: k8s.CoreV1Api, appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<void> {
const redisName = `${ctx.appName}-redis`;
// Create PVC for Redis persistence
await this.createPVC(coreApi, ctx.namespace, `${redisName}-data`, '1Gi');
// Only create Redis password secret if it doesn't already exist
try {
await coreApi.readNamespacedSecret({
name: `${redisName}-secret`,
namespace: ctx.namespace,
});
this.logger.log(`Redis secret ${redisName}-secret already exists, skipping`);
} catch {
const redisPassword = this.generatePassword(16);
const redisSecret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: `${redisName}-secret`, namespace: ctx.namespace },
data: {
password: Buffer.from(redisPassword).toString('base64'),
},
};
await coreApi.createNamespacedSecret({
namespace: ctx.namespace,
body: redisSecret,
});
}
const logShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'redis', redisName);
// Create Redis Deployment
const redisDeployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { name: redisName, namespace: ctx.namespace },
spec: {
replicas: 1,
selector: { matchLabels: { app: redisName } },
template: {
metadata: { labels: { app: redisName } },
spec: {
containers: [
{
name: 'redis',
image: `redis:${ctx.redisVersion}-alpine`,
args: ['--requirepass', '$(REDIS_PASSWORD)'],
ports: [{ containerPort: 6379 }],
env: [
{
name: 'REDIS_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${redisName}-secret`,
key: 'password',
},
},
},
],
volumeMounts: [{ name: 'redis-data', mountPath: '/data' }],
resources: {
requests: { cpu: '50m', memory: '64Mi' },
limits: { cpu: '200m', memory: '256Mi' },
},
readinessProbe: {
exec: { command: ['redis-cli', 'ping'] },
initialDelaySeconds: 5,
periodSeconds: 10,
},
livenessProbe: {
exec: { command: ['redis-cli', 'ping'] },
initialDelaySeconds: 15,
periodSeconds: 20,
},
},
...logShipper.containers,
],
volumes: [
{
name: 'redis-data',
persistentVolumeClaim: { claimName: `${redisName}-data` },
},
...logShipper.volumes,
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment({
name: redisName,
namespace: ctx.namespace,
body: redisDeployment,
});
} catch {
await appsApi.createNamespacedDeployment({
namespace: ctx.namespace,
body: redisDeployment,
});
}
// Create Redis Service
const redisService = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: redisName, namespace: ctx.namespace },
spec: {
selector: { app: redisName },
ports: [{ port: 6379, targetPort: 6379 as any, protocol: 'TCP' }],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService({
name: redisName,
namespace: ctx.namespace,
body: redisService,
});
} catch {
await coreApi.createNamespacedService({
namespace: ctx.namespace,
body: redisService,
});
}
this.logger.log(`Redis deployed for ${ctx.appName}`);
}
/**
* Deploy RabbitMQ for an application
*/
private async deployRabbitmq(coreApi: k8s.CoreV1Api, appsApi: k8s.AppsV1Api, ctx: ManifestContext): Promise<void> {
const rabbitName = `${ctx.appName}-rabbitmq`;
// Create PVC for RabbitMQ persistence
await this.createPVC(coreApi, ctx.namespace, `${rabbitName}-data`, '2Gi');
// Only create RabbitMQ credentials secret if it doesn't already exist
try {
await coreApi.readNamespacedSecret({
name: `${rabbitName}-secret`,
namespace: ctx.namespace,
});
this.logger.log(`RabbitMQ secret ${rabbitName}-secret already exists, skipping`);
} catch {
const rabbitUser = 'appuser';
const rabbitPassword = this.generatePassword(16);
const rabbitSecret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: `${rabbitName}-secret`, namespace: ctx.namespace },
data: {
username: Buffer.from(rabbitUser).toString('base64'),
password: Buffer.from(rabbitPassword).toString('base64'),
},
};
await coreApi.createNamespacedSecret({
namespace: ctx.namespace,
body: rabbitSecret,
});
}
const rabbitLogShipper = await this.attachWorkloadLogShipper(coreApi, ctx, 'rabbitmq', rabbitName);
// Create RabbitMQ Deployment
const rabbitDeployment: k8s.V1Deployment = {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: { name: rabbitName, namespace: ctx.namespace },
spec: {
replicas: 1,
selector: { matchLabels: { app: rabbitName } },
template: {
metadata: { labels: { app: rabbitName } },
spec: {
containers: [
{
name: 'rabbitmq',
image: `rabbitmq:${ctx.rabbitmqVersion}-management-alpine`,
ports: [
{ containerPort: 5672, name: 'amqp' },
{ containerPort: 15672, name: 'management' },
],
env: [
{
name: 'RABBITMQ_DEFAULT_USER',
valueFrom: {
secretKeyRef: {
name: `${rabbitName}-secret`,
key: 'username',
},
},
},
{
name: 'RABBITMQ_DEFAULT_PASS',
valueFrom: {
secretKeyRef: {
name: `${rabbitName}-secret`,
key: 'password',
},
},
},
],
volumeMounts: [{ name: 'rabbitmq-data', mountPath: '/var/lib/rabbitmq' }],
resources: {
requests: { cpu: '100m', memory: '256Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
readinessProbe: {
exec: { command: ['rabbitmq-diagnostics', '-q', 'ping'] },
initialDelaySeconds: 20,
periodSeconds: 10,
timeoutSeconds: 5,
},
livenessProbe: {
exec: { command: ['rabbitmq-diagnostics', '-q', 'status'] },
initialDelaySeconds: 60,
periodSeconds: 30,
timeoutSeconds: 10,
},
},
...rabbitLogShipper.containers,
],
volumes: [
{
name: 'rabbitmq-data',
persistentVolumeClaim: { claimName: `${rabbitName}-data` },
},
...rabbitLogShipper.volumes,
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment({
name: rabbitName,
namespace: ctx.namespace,
body: rabbitDeployment,
});
} catch {
await appsApi.createNamespacedDeployment({
namespace: ctx.namespace,
body: rabbitDeployment,
});
}
// Create RabbitMQ Services (AMQP and Management)
const rabbitService = {
apiVersion: 'v1',
kind: 'Service',
metadata: { name: rabbitName, namespace: ctx.namespace },
spec: {
selector: { app: rabbitName },
ports: [
{
port: 5672,
targetPort: 5672 as any,
protocol: 'TCP',
name: 'amqp',
},
{
port: 15672,
targetPort: 15672 as any,
protocol: 'TCP',
name: 'management',
},
],
type: 'ClusterIP',
},
};
try {
await coreApi.replaceNamespacedService({
name: rabbitName,
namespace: ctx.namespace,
body: rabbitService,
});
} catch {
await coreApi.createNamespacedService({
namespace: ctx.namespace,
body: rabbitService,
});
}
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> {
return this.k8sLifecycleService.getPodLogs(app);
}
async scaleDeployment(app: Application, replicas: number): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
await appsApi.patchNamespacedDeployment({ name: app.name, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
}
/** All K8s Deployments that belong to an application stack (default replica targets). */
private getApplicationWorkloadDeployments(app: Application): { name: string; runningReplicas: number }[] {
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 });
}
if (app.enableRedis) {
workloads.push({ name: `${app.name}-redis`, runningReplicas: 1 });
}
if (app.enableRabbitmq) {
workloads.push({ name: `${app.name}-rabbitmq`, runningReplicas: 1 });
}
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.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const snapshot: Record<string, number> = {};
for (const workload of this.getApplicationWorkloadDeployments(app)) {
try {
const deployment = await appsApi.readNamespacedDeployment({
name: workload.name,
namespace,
});
snapshot[workload.name] = deployment.spec?.replicas ?? workload.runningReplicas;
} catch (e: any) {
if (e?.code === 404 || e?.response?.statusCode === 404) continue;
snapshot[workload.name] = workload.runningReplicas;
}
}
return snapshot;
}
private async patchDeploymentReplicas(appsApi: k8s.AppsV1Api, namespace: string, deploymentName: string, replicas: number): Promise<void> {
await appsApi.patchNamespacedDeployment({ name: deploymentName, namespace, body: { spec: { replicas } } }, k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'));
}
/**
* Suspend an application by scaling all stack deployments to 0 replicas.
* Returns the replica snapshot captured before scaling.
*/
async suspendApplication(app: Application): Promise<Record<string, number>> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
this.logger.log(`Suspending application ${app.name} in namespace ${namespace}`);
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`);
} catch (e: any) {
if (e?.response?.statusCode !== 404) {
this.logger.warn(`Failed to scale ${workload.name}: ${e.message}`);
}
}
}
await this.deleteTemporaryAccessServicesForApp(app);
return snapshot;
}
/**
* Resume a suspended application using saved replica counts when available.
*/
async resumeApplication(app: Application): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
this.logger.log(`Resuming application ${app.name} in namespace ${namespace}`);
const workloads = this.resolveWorkloadReplicas(app);
const dependencies = workloads.filter((w) => w.name !== app.name);
const main = workloads.find((w) => w.name === app.name);
for (const workload of dependencies) {
try {
await this.patchDeploymentReplicas(appsApi, namespace, workload.name, workload.runningReplicas);
this.logger.log(`Scaled ${workload.name} to ${workload.runningReplicas} replica(s)`);
} catch (e: any) {
if (e?.response?.statusCode !== 404) {
this.logger.warn(`Failed to scale ${workload.name}: ${e.message}`);
}
}
}
if (!main) return;
try {
await this.patchDeploymentReplicas(appsApi, namespace, main.name, main.runningReplicas);
this.logger.log(`Scaled ${main.name} to ${main.runningReplicas} replica(s)`);
} catch (e: any) {
this.logger.warn(`Failed to scale ${main.name}: ${e.message}`);
throw e;
}
}
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const deploymentName = isManagedProductType(app.productType) ? this.primaryWorkloadLabel(app) : app.name;
await appsApi.patchNamespacedDeployment(
{
name: deploymentName,
namespace,
body: {
spec: {
template: {
metadata: {
annotations: {
'kubectl.kubernetes.io/restartedAt': new Date().toISOString(),
},
},
},
},
},
},
k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'),
);
}
/** Map optional workload keys to Deployment + primary container name */
workloadDeploymentTarget(app: Application, workload: 'app' | 'database' | 'redis' | 'rabbitmq'): { deploymentName: string; containerName: string } | null {
switch (workload) {
case 'app':
return { deploymentName: app.name, containerName: app.name };
case 'database':
if (!app.databaseType || app.databaseType === DatabaseType.NONE) return null;
return {
deploymentName: `${app.name}-db`,
containerName: `${app.name}-db`,
};
case 'redis':
if (!app.enableRedis) return null;
return { deploymentName: `${app.name}-redis`, containerName: 'redis' };
case 'rabbitmq':
if (!app.enableRabbitmq) return null;
return {
deploymentName: `${app.name}-rabbitmq`,
containerName: 'rabbitmq',
};
default:
return null;
}
}
private async fetchNamespacePodMetrics(kc: k8s.KubeConfig, namespace: string, appLabelValue: string): Promise<any[]> {
try {
const opts: any = {};
await kc.applyToHTTPSOptions(opts);
const metricsUrl = `${kc.getCurrentCluster()?.server}/apis/metrics.k8s.io/v1beta1/namespaces/${namespace}/pods`;
const https = require('https');
const http = require('http');
const url = new URL(metricsUrl);
const labelQ = `labelSelector=${encodeURIComponent(`app=${appLabelValue}`)}`;
return await new Promise((resolve) => {
const client = url.protocol === 'https:' ? https : http;
const reqOpts: any = {
hostname: url.hostname,
port: url.port,
path: `${url.pathname}?${labelQ}`,
method: 'GET',
headers: opts.headers || {},
rejectUnauthorized: false,
};
if (opts.ca) reqOpts.ca = opts.ca;
if (opts.cert) reqOpts.cert = opts.cert;
if (opts.key) reqOpts.key = opts.key;
const req = client.request(reqOpts, (res: any) => {
let data = '';
res.on('data', (chunk: string) => (data += chunk));
res.on('end', () => {
try {
const parsed = JSON.parse(data);
const items = (parsed.items || []).map((item: any) => ({
name: item.metadata?.name,
containers: (item.containers || []).map((c: any) => ({
name: c.name,
cpu: c.usage?.cpu || '0',
memory: c.usage?.memory || '0',
})),
cpu: item.containers?.[0]?.usage?.cpu || '0',
memory: item.containers?.[0]?.usage?.memory || '0',
}));
resolve(items);
} catch {
resolve([]);
}
});
});
req.on('error', () => resolve([]));
req.end();
});
} catch {
return [];
}
}
private async getSingleWorkloadUsage(
app: Application,
namespace: string,
appsApi: k8s.AppsV1Api,
coreApi: k8s.CoreV1Api,
kc: k8s.KubeConfig,
opts: {
key: string;
title: string;
deploymentName: string;
primaryContainerName: string;
fallbackCpuRequest?: string;
fallbackCpuLimit?: string;
fallbackMemoryRequest?: string;
fallbackMemoryLimit?: string;
fallbackReplicas?: number;
},
): Promise<any | null> {
let deployment: k8s.V1Deployment | null = null;
try {
const depResponse = await appsApi.readNamespacedDeployment({
name: opts.deploymentName,
namespace,
});
deployment = depResponse;
} catch {
return null;
}
const podsResponse = await coreApi.listNamespacedPod({
namespace,
labelSelector: `app=${opts.deploymentName}`,
});
const pods = podsResponse.items.map((pod) => ({
name: pod.metadata?.name,
status: pod.status?.phase,
ready: pod.status?.conditions?.find((c) => c.type === 'Ready')?.status === 'True',
restarts: pod.status?.containerStatuses?.reduce((s, c) => s + (c.restartCount || 0), 0) || 0,
startedAt: pod.status?.startTime,
}));
const podMetrics = await this.fetchNamespacePodMetrics(kc, namespace, opts.deploymentName);
const container = deployment.spec?.template?.spec?.containers?.find((c) => c.name === opts.primaryContainerName) || deployment.spec?.template?.spec?.containers?.[0];
const sidecars = (deployment.spec?.template?.spec?.containers || [])
.filter((c) => c.name !== opts.primaryContainerName)
.map((c) => ({
name: c.name,
cpuRequest: c.resources?.requests?.cpu,
cpuLimit: c.resources?.limits?.cpu,
memoryRequest: c.resources?.requests?.memory,
memoryLimit: c.resources?.limits?.memory,
}));
const configured = {
cpuRequest: container?.resources?.requests?.cpu || opts.fallbackCpuRequest,
cpuLimit: container?.resources?.limits?.cpu || opts.fallbackCpuLimit,
memoryRequest: container?.resources?.requests?.memory || opts.fallbackMemoryRequest,
memoryLimit: container?.resources?.limits?.memory || opts.fallbackMemoryLimit,
replicas: deployment.spec?.replicas ?? opts.fallbackReplicas ?? 1,
readyReplicas: deployment.status?.readyReplicas || 0,
availableReplicas: deployment.status?.availableReplicas || 0,
};
const metrics = podMetrics.map((m: any) => {
const matchC = m.containers?.find((c: any) => c.name === opts.primaryContainerName) || m.containers?.[0];
return {
name: m.name,
cpu: matchC?.cpu || m.cpu || '0',
memory: matchC?.memory || m.memory || '0',
containers: m.containers,
};
});
return {
key: opts.key,
title: opts.title,
deploymentName: opts.deploymentName,
configured,
pods,
metrics,
sidecars: sidecars.length ? sidecars : undefined,
};
}
/**
* Get real-time resource usage (CPU/Memory) for an app's pods via metrics-server.
* Includes application workload, database, and optional Redis / RabbitMQ when enabled.
*/
async getResourceUsage(app: Application): Promise<any> {
const { coreApi, appsApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const workloads: any[] = [];
const appW = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, {
key: 'app',
title: 'Application',
deploymentName: app.name,
primaryContainerName: app.name,
fallbackCpuRequest: app.cpuRequest,
fallbackCpuLimit: app.cpuLimit,
fallbackMemoryRequest: app.memoryRequest,
fallbackMemoryLimit: app.memoryLimit,
fallbackReplicas: app.replicas,
});
if (appW) workloads.push(appW);
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
const dbW = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, {
key: 'database',
title: 'Database',
deploymentName: `${app.name}-db`,
primaryContainerName: `${app.name}-db`,
fallbackCpuRequest: '100m',
fallbackCpuLimit: '500m',
fallbackMemoryRequest: '256Mi',
fallbackMemoryLimit: '512Mi',
fallbackReplicas: 1,
});
if (dbW) workloads.push(dbW);
}
if (app.enableRedis) {
const r = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, {
key: 'redis',
title: 'Redis',
deploymentName: `${app.name}-redis`,
primaryContainerName: 'redis',
fallbackCpuRequest: '50m',
fallbackCpuLimit: '200m',
fallbackMemoryRequest: '64Mi',
fallbackMemoryLimit: '256Mi',
fallbackReplicas: 1,
});
if (r) workloads.push(r);
}
if (app.enableRabbitmq) {
const mq = await this.getSingleWorkloadUsage(app, namespace, appsApi, coreApi, kc, {
key: 'rabbitmq',
title: 'RabbitMQ',
deploymentName: `${app.name}-rabbitmq`,
primaryContainerName: 'rabbitmq',
fallbackCpuRequest: '100m',
fallbackCpuLimit: '500m',
fallbackMemoryRequest: '256Mi',
fallbackMemoryLimit: '512Mi',
fallbackReplicas: 1,
});
if (mq) workloads.push(mq);
}
const primary = workloads.find((w) => w.key === 'app') || workloads[0];
const loggingNote = app.enableElasticsearch ? 'Elasticsearch log forwarding is enabled via Fluent Bit sidecar on application pods.' : undefined;
return {
workloads,
loggingNote,
configured: primary?.configured,
pods: primary?.pods || [],
metrics: primary?.metrics || [],
};
}
/**
* Update resource limits/requests and replicas on a live K8s deployment.
* @param workload Which deployment to patch (default: main application). Replicas only apply to `app`.
*/
async updateResources(
app: Application,
resources: {
cpuRequest?: string;
cpuLimit?: string;
memoryRequest?: string;
memoryLimit?: string;
replicas?: number;
},
workload: 'app' | 'database' | 'redis' | 'rabbitmq' = 'app',
): Promise<void> {
const { appsApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const target = this.workloadDeploymentTarget(app, workload);
if (!target) {
throw new BadRequestException(workload === 'database' ? 'This application does not have a database deployment.' : `Workload "${workload}" is not enabled for this application.`);
}
const patch: any = { spec: {} };
if (resources.replicas !== undefined && workload === 'app') {
patch.spec.replicas = resources.replicas;
}
if (resources.cpuRequest || resources.cpuLimit || resources.memoryRequest || resources.memoryLimit) {
patch.spec.template = {
spec: {
containers: [
{
name: target.containerName,
resources: {
requests: {
...(resources.cpuRequest && { cpu: resources.cpuRequest }),
...(resources.memoryRequest && {
memory: resources.memoryRequest,
}),
},
limits: {
...(resources.cpuLimit && { cpu: resources.cpuLimit }),
...(resources.memoryLimit && {
memory: resources.memoryLimit,
}),
},
},
},
],
},
};
}
await appsApi.patchNamespacedDeployment({ name: target.deploymentName, namespace, body: patch }, k8s.setHeaderOptions('Content-Type', 'application/strategic-merge-patch+json'));
this.logger.log(`Updated resources for ${target.deploymentName} (${workload}): ${JSON.stringify(resources)}`);
}
getUserNamespace(userId: string): string {
return userNamespace(userId);
}
/** Preview URL stays available until the custom domain is verified (not merely requested). */
private hasVerifiedCustomDomain(app: Application): boolean {
return !!(app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED);
}
private getClusterHostIp(kc: k8s.KubeConfig): string {
const clusterServer = kc.getCurrentCluster()?.server || '';
try {
return new URL(clusterServer).hostname;
} catch {
return '127.0.0.1';
}
}
private toDnsLabel(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 63)
.replace(/-$/g, '');
}
resolveAccessTarget(
app: Application,
target: ServiceAccessTarget,
): {
selector: Record<string, string>;
targetPort: number;
portName?: string;
} {
switch (target) {
case ServiceAccessTarget.DATABASE: {
if (!app.databaseType || app.databaseType === DatabaseType.NONE) {
throw new BadRequestException('Application has no database');
}
let targetPort = 3306;
if (app.databaseType === DatabaseType.POSTGRESQL) targetPort = 5432;
else if (app.databaseType === DatabaseType.MONGODB) targetPort = 27017;
return { selector: { app: `${app.name}-db` }, targetPort };
}
case ServiceAccessTarget.REDIS:
if (!app.enableRedis) throw new BadRequestException('Redis is not enabled for this application');
return { selector: { app: `${app.name}-redis` }, targetPort: 6379 };
case ServiceAccessTarget.RABBITMQ_AMQP:
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled for this application');
return {
selector: { app: `${app.name}-rabbitmq` },
targetPort: 5672,
portName: 'amqp',
};
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled for this application');
return {
selector: { app: `${app.name}-rabbitmq` },
targetPort: 15672,
portName: 'management',
};
default:
throw new BadRequestException(`Unknown access target: ${target}`);
}
}
async createTemporaryAccess(
app: Application,
target: ServiceAccessTarget,
grantId: string,
): Promise<{
host: string;
nodePort: number;
k8sServiceName: string;
targetPort: number;
}> {
if (!app.clusterId) {
throw new BadRequestException('Application is not assigned to a cluster');
}
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const { selector, targetPort, portName } = this.resolveAccessTarget(app, target);
const shortId = grantId.split('-')[0];
const k8sServiceName = this.toDnsLabel(`${app.name}-${target}-access-${shortId}`);
const portSpec: k8s.V1ServicePort = {
port: targetPort,
targetPort: targetPort,
protocol: 'TCP',
};
if (portName) portSpec.name = portName;
const service: k8s.V1Service = {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: k8sServiceName,
namespace,
labels: {
app: selector.app,
'cloudhost.io/access-grant': 'true',
'cloudhost.io/grant-id': grantId,
'cloudhost.io/application-id': app.id,
},
},
spec: {
type: 'NodePort',
selector,
ports: [portSpec],
},
};
let created: k8s.V1Service;
try {
created = await coreApi.createNamespacedService({
namespace,
body: service,
});
} catch (error: any) {
const message = error.body?.message || error.response?.body?.message || error.message || 'HTTP request failed';
this.logger.warn(`K8s client failed to create temporary access service ${k8sServiceName}; trying kubectl fallback: ${message}`);
created = await this.applyServiceWithKubectl(app.clusterId, namespace, service);
}
const nodePort = created.spec?.ports?.[0]?.nodePort;
if (!nodePort) {
try {
await coreApi.deleteNamespacedService({
name: k8sServiceName,
namespace,
});
} catch {}
throw new Error('Failed to allocate NodePort for temporary access');
}
const host = this.getClusterHostIp(kc);
this.logger.log(`Temporary access for ${app.name} target=${target}: ${host}:${nodePort} (service ${k8sServiceName})`);
return { host, nodePort, k8sServiceName, targetPort };
}
private async applyServiceWithKubectl(clusterId: string | undefined, namespace: string, service: k8s.V1Service): Promise<k8s.V1Service> {
const tmpDir = fs.mkdtempSync(path.join('/tmp', 'cloudhost-access-'));
const kubeconfigPath = path.join(tmpDir, 'kubeconfig.yaml');
const manifestPath = path.join(tmpDir, 'service.json');
try {
fs.writeFileSync(kubeconfigPath, await this.k8sClientService.getKubeconfig(clusterId), {
mode: 0o600,
});
fs.writeFileSync(manifestPath, JSON.stringify(service), { mode: 0o600 });
await execFileAsync('kubectl', ['--kubeconfig', kubeconfigPath, 'apply', '-n', namespace, '-f', manifestPath]);
const { stdout } = await execFileAsync('kubectl', ['--kubeconfig', kubeconfigPath, 'get', 'service', service.metadata!.name!, '-n', namespace, '-o', 'json']);
return JSON.parse(stdout) as k8s.V1Service;
} catch (error: any) {
const message = error.stderr || error.message || 'kubectl failed';
throw new Error(`Failed to create temporary access service: ${message}`);
} finally {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {}
}
}
async revokeTemporaryAccess(clusterId: string, namespace: string, k8sServiceName: string): Promise<void> {
const { coreApi } = await this.k8sClientService.getK8sClient(clusterId);
try {
await coreApi.deleteNamespacedService({
name: k8sServiceName,
namespace,
});
this.logger.log(`Revoked temporary access service ${k8sServiceName} in ${namespace}`);
} catch (e: any) {
if (e.code !== 404 && e.response?.statusCode !== 404) {
this.logger.warn(`Failed to delete access service ${k8sServiceName}: ${e.message}`);
}
}
}
async deleteTemporaryAccessServicesForApp(app: Application): Promise<void> {
if (!app.clusterId) return;
const namespace = this.getUserNamespace(app.userId);
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
try {
const services = await coreApi.listNamespacedService({
namespace,
labelSelector: 'cloudhost.io/access-grant=true',
});
for (const svc of services.items) {
const appId = svc.metadata?.labels?.['cloudhost.io/application-id'];
if (appId === app.id && svc.metadata?.name) {
await this.revokeTemporaryAccess(app.clusterId, namespace, svc.metadata.name);
}
}
} catch (e: any) {
this.logger.warn(`Failed to list temporary access services for ${app.name}: ${e.message}`);
}
}
async readAccessCredentials(app: Application, target: ServiceAccessTarget): Promise<Record<string, string | number | undefined>> {
const namespace = this.getUserNamespace(app.userId);
switch (target) {
case ServiceAccessTarget.DATABASE:
return {
username: app.dbUsername || 'appuser',
password: app.dbPassword || undefined,
database: app.name.replace(/-/g, '_'),
};
case ServiceAccessTarget.REDIS: {
if (!app.clusterId) return {};
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const secret = await coreApi.readNamespacedSecret({
name: `${app.name}-redis-secret`,
namespace,
});
const password = secret.data?.password ? Buffer.from(secret.data.password, 'base64').toString('utf8') : undefined;
return { password };
}
case ServiceAccessTarget.RABBITMQ_AMQP:
case ServiceAccessTarget.RABBITMQ_MANAGEMENT: {
if (!app.clusterId) return {};
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const secret = await coreApi.readNamespacedSecret({
name: `${app.name}-rabbitmq-secret`,
namespace,
});
const username = secret.data?.username ? Buffer.from(secret.data.username, 'base64').toString('utf8') : 'appuser';
const password = secret.data?.password ? Buffer.from(secret.data.password, 'base64').toString('utf8') : undefined;
return { username, password };
}
default:
return {};
}
}
/**
* Get preview info for a deployed application.
* Returns ingress URL when available; only reads an existing NodePort (never patches ClusterIP).
*/
async getPreviewInfo(
app: Application,
previewNumber?: string | null,
): Promise<{
url: string;
nodePort: number;
host: string;
ingressUrl?: string;
}> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const domain = this.configService.get('platform.domain');
const hostIp = this.getClusterHostIp(kc);
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 = userIdSlug(app.userId);
let ingressUrl = `https://${subdomain}.${domain}`;
if (verifiedCustomDomain) {
ingressUrl = `https://${verifiedCustomDomain}`;
} else if (previewNumber) {
ingressUrl = `https://${namespacePrefix}-${previewNumber}.${previewRootDomain}`;
}
let nodePort = 0;
try {
const svcResponse = await coreApi.readNamespacedService({
name: app.name,
namespace,
});
if (svcResponse.spec?.type === 'NodePort') {
nodePort = svcResponse.spec.ports?.[0]?.nodePort || 0;
}
} catch (e: any) {
this.logger.warn(`Failed to read service for ${app.name}: ${e.message}`);
}
const url = ingressUrl || (nodePort > 0 ? `http://${hostIp}:${nodePort}` : '');
return {
url,
nodePort,
host: hostIp,
ingressUrl,
};
}
async deleteApplication(app: Application): Promise<void> {
const namespace = this.getUserNamespace(app.userId);
const { coreApi, appsApi, networkingApi } = await this.k8sClientService.getK8sClient(app.clusterId);
await this.deleteTemporaryAccessServicesForApp(app);
// Step 1: Try Helm uninstall (handles most resources)
try {
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.helmService.uninstall(app.name, namespace, kubeconfig);
this.logger.log(`Helm release ${app.name} uninstalled from ${namespace}`);
} catch (error: any) {
this.logger.warn(`Helm uninstall failed for ${app.name}: ${error.message} — proceeding with manual cleanup`);
// Manual cleanup of core resources if helm wasn't managing them
try {
await appsApi.deleteNamespacedDeployment({ name: app.name, namespace });
} catch {}
try {
await coreApi.deleteNamespacedService({ name: app.name, namespace });
} catch {}
try {
await networkingApi.deleteNamespacedIngress({
name: app.name,
namespace,
});
} catch {}
}
// Step 2: Delete resources with "helm.sh/resource-policy: keep" (PVCs, db-secret)
// These survive helm uninstall by design, so we must delete them explicitly
const dbName = `${app.name}-db`;
const resourcesToDelete = [
// Database resources
() => appsApi.deleteNamespacedDeployment({ name: dbName, namespace }),
() => coreApi.deleteNamespacedService({ name: dbName, namespace }),
() =>
coreApi.deleteNamespacedPersistentVolumeClaim({
name: dbName,
namespace,
}),
() =>
coreApi.deleteNamespacedSecret({
name: `${app.name}-db-secret`,
namespace,
}),
// App storage PVC (all app types)
() =>
coreApi.deleteNamespacedPersistentVolumeClaim({
name: `${app.name}-storage`,
namespace,
}),
// Legacy: also try deleting old wp-content PVC name for backward compatibility
() =>
coreApi.deleteNamespacedPersistentVolumeClaim({
name: `${app.name}-wp-content`,
namespace,
}),
// App env secret
() => coreApi.deleteNamespacedSecret({ name: `${app.name}-env`, namespace }),
// Fluent Bit config (if elasticsearch was enabled)
() =>
coreApi.deleteNamespacedConfigMap({
name: `${app.name}-fluent-bit-config`,
namespace,
}),
// Redis resources
() =>
appsApi.deleteNamespacedDeployment({
name: `${app.name}-redis`,
namespace,
}),
() =>
coreApi.deleteNamespacedService({
name: `${app.name}-redis`,
namespace,
}),
() =>
coreApi.deleteNamespacedPersistentVolumeClaim({
name: `${app.name}-redis-data`,
namespace,
}),
() =>
coreApi.deleteNamespacedSecret({
name: `${app.name}-redis-secret`,
namespace,
}),
// RabbitMQ resources
() =>
appsApi.deleteNamespacedDeployment({
name: `${app.name}-rabbitmq`,
namespace,
}),
() =>
coreApi.deleteNamespacedService({
name: `${app.name}-rabbitmq`,
namespace,
}),
() =>
coreApi.deleteNamespacedPersistentVolumeClaim({
name: `${app.name}-rabbitmq-data`,
namespace,
}),
() =>
coreApi.deleteNamespacedSecret({
name: `${app.name}-rabbitmq-secret`,
namespace,
}),
// TLS secret created by cert-manager
() => coreApi.deleteNamespacedSecret({ name: `${app.name}-tls`, namespace }),
];
for (const deleteFn of resourcesToDelete) {
try {
await deleteFn();
} catch {}
}
this.logger.log(`All K8s resources cleaned up for ${app.name} in ${namespace}`);
}
async prepareApplicationMigration(
app: Application,
targetClusterId: string,
options: {
migrateStorage?: boolean;
log?: (step: string, message: string, metadata?: Record<string, any>) => Promise<void>;
} = {},
): Promise<void> {
const namespace = this.getUserNamespace(app.userId);
const source = await this.k8sClientService.getK8sClient(app.clusterId);
const target = await this.k8sClientService.getK8sClient(targetClusterId);
await this.ensureNamespaceOnCluster(target.coreApi, namespace);
await options.log?.('transfer-secrets-configs', 'Target namespace ensured', { namespace });
const secretNames = [`${app.name}-env`, `${app.name}-db-secret`, `${app.name}-redis-secret`, `${app.name}-rabbitmq-secret`, `${app.name}-tls`];
for (const secretName of secretNames) {
try {
const secret = await source.coreApi.readNamespacedSecret({
name: secretName,
namespace,
});
await this.upsertSecret(target.coreApi, namespace, this.cleanK8sObject(secret));
await options.log?.('transfer-secrets-configs', `Secret ${secretName} copied`);
} catch (error: any) {
if (!this.isK8sNotFound(error)) {
throw error;
}
}
}
const configMapNames = [`${app.name}-fluent-bit-config`];
for (const configMapName of configMapNames) {
try {
const configMap = await source.coreApi.readNamespacedConfigMap({
name: configMapName,
namespace,
});
await this.upsertConfigMap(target.coreApi, namespace, this.cleanK8sObject(configMap));
await options.log?.('transfer-secrets-configs', `ConfigMap ${configMapName} copied`);
} catch (error: any) {
if (!this.isK8sNotFound(error)) {
throw error;
}
}
}
if (options.migrateStorage !== false) {
const pvcNames = [`${app.name}-storage`, `${app.name}-wp-content`, `${app.name}-db`, `${app.name}-redis-data`, `${app.name}-rabbitmq-data`];
for (const pvcName of pvcNames) {
try {
const pvc = await source.coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
await this.upsertPvcDefinition(target.coreApi, namespace, this.cleanPvcForMigration(pvc));
await options.log?.('transfer-volumes', `PVC definition ${pvcName} prepared on target cluster`, {
note: 'Starting best-effort PVC data copy through migration helper pods.',
});
await this.copyPvcDataBetweenClusters(app.clusterId!, targetClusterId, namespace, pvcName, options.log);
} catch (error: any) {
if (!this.isK8sNotFound(error)) {
throw error;
}
}
}
}
}
private async copyPvcDataBetweenClusters(
sourceClusterId: string,
targetClusterId: string,
namespace: string,
pvcName: string,
log?: (step: string, message: string, metadata?: Record<string, any>) => Promise<void>,
): Promise<void> {
const safeName = pvcName
.replace(/[^a-z0-9-]/gi, '-')
.toLowerCase()
.slice(0, 32);
const suffix = `${Date.now()}`.slice(-6);
const sourcePod = `migrate-src-${safeName}-${suffix}`;
const targetPod = `migrate-dst-${safeName}-${suffix}`;
const tempDir = path.join('/tmp', `app-migration-${safeName}-${suffix}`);
const sourceKubeconfig = path.join(tempDir, 'source.kubeconfig');
const targetKubeconfig = path.join(tempDir, 'target.kubeconfig');
fs.mkdirSync(tempDir, { recursive: true });
fs.writeFileSync(sourceKubeconfig, await this.k8sClientService.getKubeconfig(sourceClusterId), { mode: 0o600 });
fs.writeFileSync(targetKubeconfig, await this.k8sClientService.getKubeconfig(targetClusterId), { mode: 0o600 });
try {
await this.createPvcCopyPod(sourceKubeconfig, namespace, sourcePod, pvcName);
await this.createPvcCopyPod(targetKubeconfig, namespace, targetPod, pvcName);
await log?.('transfer-volumes', `Copy helper pods ready for PVC ${pvcName}`);
const localDataPath = path.join(tempDir, 'data');
await execFileAsync('kubectl', ['--kubeconfig', sourceKubeconfig, '-n', namespace, 'cp', `${sourcePod}:/data`, localDataPath], {
timeout: 30 * 60 * 1000,
});
await execFileAsync('kubectl', ['--kubeconfig', targetKubeconfig, '-n', namespace, 'cp', `${localDataPath}/.`, `${targetPod}:/data`], {
timeout: 30 * 60 * 1000,
});
await log?.('transfer-volumes', `PVC data copied for ${pvcName}`);
} finally {
await execFileAsync('kubectl', ['--kubeconfig', sourceKubeconfig, '-n', namespace, 'delete', 'pod', sourcePod, '--ignore-not-found=true']).catch(() => undefined);
await execFileAsync('kubectl', ['--kubeconfig', targetKubeconfig, '-n', namespace, 'delete', 'pod', targetPod, '--ignore-not-found=true']).catch(() => undefined);
fs.rmSync(tempDir, { recursive: true, force: true });
}
}
private async createPvcCopyPod(kubeconfigPath: string, namespace: string, podName: string, pvcName: string): Promise<void> {
const manifestPath = path.join('/tmp', `${podName}.json`);
const manifest = {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name: podName, namespace },
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'copy',
image: 'busybox:1.36',
command: ['sh', '-c', 'mkdir -p /data && sleep 3600'],
volumeMounts: [{ name: 'data', mountPath: '/data' }],
},
],
volumes: [{ name: 'data', persistentVolumeClaim: { claimName: pvcName } }],
},
};
fs.writeFileSync(manifestPath, JSON.stringify(manifest));
try {
await execFileAsync('kubectl', ['--kubeconfig', kubeconfigPath, 'apply', '-f', manifestPath], { timeout: 120_000 });
await execFileAsync('kubectl', ['--kubeconfig', kubeconfigPath, '-n', namespace, 'wait', '--for=condition=Ready', `pod/${podName}`, '--timeout=180s'], { timeout: 210_000 });
} finally {
fs.rmSync(manifestPath, { force: true });
}
}
private async ensureNamespaceOnCluster(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
try {
await coreApi.readNamespace({ name: namespace });
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespace({
body: { metadata: { name: namespace } },
});
return;
}
throw error;
}
}
private async upsertSecret(coreApi: k8s.CoreV1Api, namespace: string, secret: k8s.V1Secret): Promise<void> {
secret.metadata = { ...(secret.metadata || {}), namespace };
try {
await coreApi.replaceNamespacedSecret({
name: secret.metadata.name!,
namespace,
body: secret,
});
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespacedSecret({ namespace, body: secret });
return;
}
throw error;
}
}
private async upsertConfigMap(coreApi: k8s.CoreV1Api, namespace: string, configMap: k8s.V1ConfigMap): Promise<void> {
configMap.metadata = { ...(configMap.metadata || {}), namespace };
try {
await coreApi.replaceNamespacedConfigMap({
name: configMap.metadata.name!,
namespace,
body: configMap,
});
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespacedConfigMap({ namespace, body: configMap });
return;
}
throw error;
}
}
private async upsertPvcDefinition(coreApi: k8s.CoreV1Api, namespace: string, pvc: k8s.V1PersistentVolumeClaim): Promise<void> {
pvc.metadata = { ...(pvc.metadata || {}), namespace };
try {
await coreApi.readNamespacedPersistentVolumeClaim({
name: pvc.metadata.name!,
namespace,
});
} catch (error: any) {
if (this.isK8sNotFound(error)) {
await coreApi.createNamespacedPersistentVolumeClaim({
namespace,
body: pvc,
});
return;
}
throw error;
}
}
private cleanK8sObject<T extends { metadata?: k8s.V1ObjectMeta }>(obj: T): T {
const metadata = { ...(obj.metadata || {}) };
delete metadata.uid;
delete metadata.resourceVersion;
delete metadata.generation;
delete metadata.creationTimestamp;
delete metadata.managedFields;
delete metadata.selfLink;
return {
...obj,
metadata,
};
}
private cleanPvcForMigration(pvc: k8s.V1PersistentVolumeClaim): k8s.V1PersistentVolumeClaim {
const cleaned = this.cleanK8sObject(pvc);
return {
apiVersion: cleaned.apiVersion,
kind: cleaned.kind,
metadata: cleaned.metadata,
spec: {
accessModes: cleaned.spec?.accessModes,
resources: cleaned.spec?.resources,
storageClassName: cleaned.spec?.storageClassName,
volumeMode: cleaned.spec?.volumeMode,
},
};
}
private isK8sNotFound(error: any): boolean {
return error?.statusCode === 404 || error?.body?.code === 404;
}
/**
* Wait for the database pod to become Ready.
* Polls pod status with label selector `app=<appName>-db`.
*/
async waitForDatabaseReady(app: Application, timeoutMs = 120_000): Promise<void> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const dbLabel = `${app.name}-db`;
const start = Date.now();
this.logger.log(`Waiting for DB pod (app=${dbLabel}) to become Ready in ${namespace}...`);
while (Date.now() - start < timeoutMs) {
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `app=${dbLabel}`,
});
for (const pod of pods.items) {
const readyCond = (pod.status?.conditions || []).find((c) => c.type === 'Ready');
if (readyCond?.status === 'True') {
this.logger.log(`DB pod ${pod.metadata?.name} is Ready (${Date.now() - start}ms)`);
return;
}
}
} catch {
// Pods may not exist yet
}
await new Promise((r) => setTimeout(r, 3000));
}
this.logger.warn(`DB pod did not become Ready within ${timeoutMs / 1000}s — proceeding anyway`);
}
private async getDeploymentReadiness(
appsApi: k8s.AppsV1Api,
namespace: string,
name: string,
expectedReplicas: number,
): Promise<{
name: string;
desiredReplicas: number;
readyReplicas: number;
availableReplicas: number;
ready: boolean;
}> {
try {
const response = await appsApi.readNamespacedDeployment({
name,
namespace,
});
const deployment = response;
const desiredReplicas = deployment.spec?.replicas ?? expectedReplicas;
const readyReplicas = deployment.status?.readyReplicas ?? 0;
const availableReplicas = deployment.status?.availableReplicas ?? 0;
const observedGeneration = deployment.status?.observedGeneration ?? 0;
const generation = deployment.metadata?.generation ?? 0;
return {
name,
desiredReplicas,
readyReplicas,
availableReplicas,
ready: desiredReplicas === 0 || (readyReplicas >= desiredReplicas && availableReplicas >= desiredReplicas && observedGeneration >= generation),
};
} catch {
return {
name,
desiredReplicas: expectedReplicas,
readyReplicas: 0,
availableReplicas: 0,
ready: false,
};
}
}
private async describeWorkloadPods(coreApi: k8s.CoreV1Api, namespace: string, workloadNames: string[]): Promise<string> {
const podLines: string[] = [];
for (const workloadName of workloadNames) {
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `app=${workloadName}`,
});
for (const pod of pods.items) {
const ready = pod.status?.conditions?.find((c) => c.type === 'Ready')?.status === 'True';
const scheduled = pod.status?.conditions?.find((c) => c.type === 'PodScheduled');
const waitingReasons = (pod.status?.containerStatuses || [])
.map((status) => status.state?.waiting?.reason)
.filter(Boolean)
.join('/');
const reason = waitingReasons || scheduled?.reason || pod.status?.reason || pod.status?.phase || 'Unknown';
const message = scheduled?.message || pod.status?.message || '';
podLines.push(`${pod.metadata?.name || workloadName}: phase=${pod.status?.phase || 'Unknown'}, ready=${ready}, reason=${reason}${message ? ` (${message})` : ''}`);
}
} catch {
podLines.push(`${workloadName}: pods unavailable`);
}
}
return podLines.length ? `Pods: ${podLines.join('; ')}` : 'No pods found for expected workloads.';
}
/**
* Restore a SQL dump file into the application's database.
* Uses a PVC + helper pod + kubectl cp to transfer the dump (supports large files),
* then runs a restore Job that mounts the PVC and imports the dump.
*/
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 = 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 dumpSize = fs.statSync(dumpFilePath).size;
const pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024)));
this.logger.log(`Restoring DB dump for ${app.name}: ${(dumpSize / 1024 / 1024).toFixed(1)} MB via PVC`);
// ── 1. Create PVC for the dump file ──
await coreApi.createNamespacedPersistentVolumeClaim({
namespace,
body: {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: pvcName, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: `${pvcSizeGi}Gi` } },
},
},
});
// ── 2. Helper pod to receive the dump via kubectl cp ──
const helperPod: k8s.V1Pod = {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name: helperPodName, namespace },
spec: {
containers: [
{
name: 'helper',
image: 'busybox:1.36',
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'dump', mountPath: '/data' }],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '256Mi' },
},
},
],
volumes: [{ name: 'dump', persistentVolumeClaim: { claimName: pvcName } }],
restartPolicy: 'Never',
},
};
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('DB dump helper pod failed to start');
await new Promise((r) => setTimeout(r, 2000));
}
// ── 3. kubectl cp the dump file into the helper pod ──
const tmpKubeconfig = path.join('/tmp', `kubeconfig-dbdump-${ts}.yaml`);
const kcYaml = kc.exportConfig();
fs.writeFileSync(tmpKubeconfig, kcYaml);
// Verify tar is available inside the helper pod
try {
await execFileAsync('kubectl', ['--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '--', 'tar', '--version'], { timeout: 30_000 });
} catch (e: any) {
this.logger.warn(`tar check in DB dump helper pod failed: ${e.message}`);
}
try {
await execFileAsync('kubectl', ['--kubeconfig', tmpKubeconfig, 'cp', dumpFilePath, `${namespace}/${helperPodName}:/data/dump.sql`, '--retries', '3'], {
maxBuffer: 50 * 1024 * 1024,
timeout: 600_000,
});
this.logger.log(`kubectl cp dump completed (${(dumpSize / 1024 / 1024).toFixed(1)} MB)`);
} finally {
try {
fs.unlinkSync(tmpKubeconfig);
} catch {}
try {
await coreApi.deleteNamespacedPod({ name: helperPodName, namespace });
} catch {}
}
// ── 4. Build restore command (per database type) ──
const { image, restoreCommand: command } = this.databaseDumpSpec(app, dbName);
// ── 5. Create the restore Job ──
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 300,
backoffLimit: 1,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'restore',
image,
command,
env: [
{
name: 'DB_USER',
valueFrom: {
secretKeyRef: {
name: `${app.name}-db-secret`,
key: 'username',
},
},
},
{
name: 'DB_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${app.name}-db-secret`,
key: 'password',
},
},
},
],
volumeMounts: [{ name: 'dump-volume', mountPath: '/dump', readOnly: true }],
resources: {
requests: { cpu: '200m', memory: '256Mi' },
limits: { cpu: '1', memory: '1Gi' },
},
},
],
volumes: [
{
name: 'dump-volume',
persistentVolumeClaim: { claimName: pvcName },
},
],
},
},
},
};
try {
await batchApi.createNamespacedJob({ namespace, body: job });
this.logger.log(`Created DB restore job ${jobName} for ${app.name}`);
} catch (e: any) {
try {
await coreApi.deleteNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
} catch {}
this.logger.error(`Failed to create restore job: ${e.message}`);
throw new Error('Failed to create database restore job');
}
// ── 6. Wait for the job to complete (max 10 minutes for large dumps) ──
const timeout = 600_000;
const start = Date.now();
let completed = false;
let failed = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 5000));
try {
const jobStatus = await batchApi.readNamespacedJob({
name: jobName,
namespace,
});
const status = jobStatus.status;
if (status?.succeeded && status.succeeded > 0) {
completed = true;
break;
}
if (status?.failed && status.failed > 0) {
failed = true;
break;
}
} catch {
// Job may not be ready yet
}
}
// ── 7. Get logs from the restore job pod ──
let logs = '';
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
if (pods.items.length > 0) {
const podName = pods.items[0].metadata?.name;
if (podName) {
const logResponse = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
});
logs = logResponse || '';
}
}
} catch (e: any) {
this.logger.warn(`Could not get restore job logs: ${e.message}`);
}
// ── 8. Clean up the dump PVC ──
try {
await coreApi.deleteNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
this.logger.log(`Cleaned up dump PVC: ${pvcName}`);
} catch {}
if (!completed && !failed) {
this.logger.warn(`DB restore job ${jobName} timed out`);
return {
success: false,
logs: logs || 'Restore job timed out after 10 minutes',
};
}
if (failed) {
this.logger.warn(`DB restore job ${jobName} failed`);
return { success: false, logs: logs || 'Restore job failed' };
}
this.logger.log(`DB restore for ${app.name} completed successfully`);
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({ name: volumeName });
scName = pv.spec?.storageClassName;
if (pv.spec?.hostPath || pv.spec?.nfs || pv.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({ name: storageClassName });
if (sc.allowVolumeExpansion) {
return { ok: true };
}
await storageApi.patchStorageClass({ name: storageClassName, body: { allowVolumeExpansion: true } }, k8s.setHeaderOptions('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(
{
name: pvcName,
namespace,
body: [
{
op: 'replace',
path: '/spec/resources/requests/storage',
value: newSize,
},
],
},
k8s.setHeaderOptions('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.k8sClientService.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = this.getUserNamespace(app.userId);
const oldPvcName = `${app.name}-db`;
const newPvcName = `${app.name}-db-resizable`;
const deploymentName = `${app.name}-db`;
try {
try {
await coreApi.readNamespacedPersistentVolumeClaim({
name: newPvcName,
namespace,
});
} catch {
await coreApi.createNamespacedPersistentVolumeClaim({
namespace,
body: {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: newPvcName, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName,
resources: { requests: { storage: newSize } },
},
},
});
}
await appsApi.patchNamespacedDeployment({ name: deploymentName, namespace, body: { spec: { replicas: 0 } } }, k8s.setHeaderOptions('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,
body: {
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({
name: deploymentName,
namespace,
});
const existingVolumes = depRes.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(
{
name: deploymentName,
namespace,
body: {
spec: {
replicas: 1,
template: { spec: { volumes: updatedVolumes } },
},
},
},
k8s.setHeaderOptions('Content-Type', 'application/strategic-merge-patch+json'),
);
try {
await coreApi.deleteNamespacedPersistentVolumeClaim({
name: 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({ name: deploymentName, namespace, body: { spec: { replicas: 1 } } }, k8s.setHeaderOptions('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.status?.readyReplicas ?? 0;
const replicas = dep.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({
name: jobName,
namespace,
});
const succeeded = job.status?.succeeded ?? 0;
const failed = job.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, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-db`;
try {
const currentPvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
const currentSize = currentPvc.spec?.resources?.requests?.storage || '1Gi';
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})`,
};
}
const scName = await this.resolvePvcStorageClassName(coreApi, currentPvc);
if (scName) {
const scCheck = await this.ensureStorageClassAllowsExpansion(kc, scName);
if (!scCheck.ok) {
return {
success: false,
message: scCheck.message || 'StorageClass does not allow expansion',
};
}
}
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',
};
}
}
/**
* Get current PVC size for an application's database.
*/
async getDatabasePvcSize(app: Application): Promise<string> {
try {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-db`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
return pvc.spec?.resources?.requests?.storage || app.dbStorageSize || '1Gi';
} catch {
return app.dbStorageSize || '1Gi';
}
}
/**
* Get comprehensive storage usage for an application.
* Includes app + DB PVCs and optional Redis/RabbitMQ data volumes when enabled.
*/
async getStorageUsage(app: Application): Promise<{
database: StorageUsageSlice | null;
appStorage: StorageUsageSlice | null;
redisStorage: StorageUsageSlice | null;
rabbitmqStorage: StorageUsageSlice | null;
totalAllocatedGb: number;
totalUsedGb: number;
}> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
const result = {
database: null as StorageUsageSlice | null,
appStorage: null as StorageUsageSlice | null,
redisStorage: null as StorageUsageSlice | null,
rabbitmqStorage: null as StorageUsageSlice | null,
totalAllocatedGb: 0,
totalUsedGb: 0,
};
const parseToGb = (size: string): number => {
if (!size) return 0;
const match = size.match(/^(\d+(?:\.\d+)?)(Ki|Mi|Gi|Ti)?$/i);
if (!match) return 0;
const value = parseFloat(match[1]);
const unit = (match[2] || 'Gi').toLowerCase();
switch (unit) {
case 'ki':
return value / (1024 * 1024);
case 'mi':
return value / 1024;
case 'gi':
return value;
case 'ti':
return value * 1024;
default:
return value;
}
};
const formatSize = (gb: number): string => {
if (gb >= 1) return `${gb.toFixed(1)}Gi`;
const mb = gb * 1024;
if (mb >= 1) return `${mb.toFixed(0)}Mi`;
return `${(mb * 1024).toFixed(0)}Ki`;
};
const makeSlice = (allocatedStr: string, allocatedGb: number, usedGb: number): StorageUsageSlice => {
const availableGb = Math.max(0, allocatedGb - usedGb);
const usedPercent = allocatedGb > 0 ? Math.round((usedGb / allocatedGb) * 100) : 0;
return {
allocatedRaw: allocatedStr,
allocatedGi: allocatedGb,
usedGi: usedGb,
availableGi: availableGb,
usedPercent,
allocated: allocatedStr,
used: formatSize(usedGb),
available: formatSize(availableGb),
};
};
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
try {
const pvcName = `${app.name}-db`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
const allocatedStr = pvc.spec?.resources?.requests?.storage || app.dbStorageSize || '1Gi';
const allocatedGb = parseToGb(allocatedStr);
let usedGb = 0;
try {
const dbDep = `${app.name}-db`;
const dbContainer = `${app.name}-db`;
if (app.databaseType === DatabaseType.POSTGRESQL) {
usedGb = await this.getPvcUsageFromPod(app, dbDep, '/var/lib/postgresql/data', namespace, dbContainer);
} else if (app.databaseType === DatabaseType.MYSQL || app.databaseType === DatabaseType.MARIADB) {
usedGb = await this.getPvcUsageFromPod(app, dbDep, '/var/lib/mysql', namespace, dbContainer);
} else if (app.databaseType === DatabaseType.MONGODB) {
usedGb = await this.getPvcUsageFromPod(app, dbDep, '/data/db', namespace, dbContainer);
}
} catch {
usedGb = allocatedGb * 0.1;
}
result.database = makeSlice(allocatedStr, allocatedGb, usedGb);
result.totalAllocatedGb += allocatedGb;
result.totalUsedGb += usedGb;
} catch (e: any) {
this.logger.warn(`Failed to get DB storage usage for ${app.name}: ${e.message}`);
}
}
try {
let pvc;
let pvcName = `${app.name}-storage`;
try {
pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
} catch {
pvcName = `${app.name}-wp-content`;
pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
}
const allocatedStr = pvc.spec?.resources?.requests?.storage || app.appStorageSize || '2Gi';
const allocatedGb = parseToGb(allocatedStr);
const mountPath = this.getStorageMountPath(app.runtime);
let usedGb = 0;
try {
usedGb = await this.getPvcUsageFromPod(app, app.name, mountPath, namespace, app.name);
} catch {
usedGb = allocatedGb * 0.1;
}
result.appStorage = makeSlice(allocatedStr, allocatedGb, usedGb);
result.totalAllocatedGb += allocatedGb;
result.totalUsedGb += usedGb;
} catch (e: any) {
this.logger.warn(`Failed to get app storage usage for ${app.name}: ${e.message}`);
}
if (app.enableRedis) {
try {
const redisPvc = `${app.name}-redis-data`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: redisPvc,
namespace,
});
const allocatedStr = pvc.spec?.resources?.requests?.storage || '1Gi';
const allocatedGb = parseToGb(allocatedStr);
let usedGb = 0;
try {
usedGb = await this.getPvcUsageFromPod(app, `${app.name}-redis`, '/data', namespace, 'redis');
} catch {
usedGb = allocatedGb * 0.05;
}
result.redisStorage = makeSlice(allocatedStr, allocatedGb, usedGb);
result.totalAllocatedGb += allocatedGb;
result.totalUsedGb += usedGb;
} catch (e: any) {
this.logger.warn(`Failed to get Redis storage usage for ${app.name}: ${e.message}`);
}
}
if (app.enableRabbitmq) {
try {
const mqPvc = `${app.name}-rabbitmq-data`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: mqPvc,
namespace,
});
const allocatedStr = pvc.spec?.resources?.requests?.storage || '1Gi';
const allocatedGb = parseToGb(allocatedStr);
let usedGb = 0;
try {
usedGb = await this.getPvcUsageFromPod(app, `${app.name}-rabbitmq`, '/var/lib/rabbitmq', namespace, 'rabbitmq');
} catch {
usedGb = allocatedGb * 0.05;
}
result.rabbitmqStorage = makeSlice(allocatedStr, allocatedGb, usedGb);
result.totalAllocatedGb += allocatedGb;
result.totalUsedGb += usedGb;
} catch (e: any) {
this.logger.warn(`Failed to get RabbitMQ storage usage for ${app.name}: ${e.message}`);
}
}
return result;
}
/**
* Get PVC usage by executing du command in a pod.
* Returns usage in GB.
*/
private async getPvcUsageFromPod(app: Application, deploymentName: string, mountPath: string, namespace: string, containerName: string): Promise<number> {
const { coreApi, kc } = await this.k8sClientService.getK8sClient(app.clusterId);
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `app=${deploymentName}`,
});
const runningPod = pods.items.find((p) => p.status?.phase === 'Running');
if (!runningPod?.metadata?.name) {
throw new Error('No running pod found');
}
const exec = new k8s.Exec(kc);
const chunks: Buffer[] = [];
const stdout = new PassThrough();
stdout.on('data', (chunk: Buffer) => chunks.push(chunk));
await new Promise<void>((resolve, reject) => {
exec.exec(namespace, runningPod.metadata!.name!, containerName, ['du', '-sb', mountPath], stdout, null, null, false, (status: k8s.V1Status) => {
if (status.status === 'Success') resolve();
else reject(new Error(status.message || 'du command failed'));
});
});
const output = Buffer.concat(chunks).toString().trim();
const bytes = parseInt(output.split(/\s+/)[0], 10) || 0;
return bytes / (1024 * 1024 * 1024);
}
/**
* Expand a named PVC (Redis, RabbitMQ, or other optional service volumes).
*/
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 = this.getUserNamespace(app.userId);
try {
const pvc = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
const currentSize = pvc.spec?.resources?.requests?.storage || '1Gi';
const parseGi = (s: string) => parseInt(String(s).replace(/Gi/i, ''), 10) || 0;
if (parseGi(newSize) <= parseGi(currentSize)) {
return {
success: false,
message: `New size (${newSize}) must be larger than current size (${currentSize})`,
};
}
await this.patchPvcStorageSize(coreApi, pvcName, namespace, newSize);
this.logger.log(`Expanded ${pvcName} from ${currentSize} to ${newSize}`);
return {
success: true,
message: `${label} storage expanded from ${currentSize} to ${newSize}`,
};
} catch (e: any) {
this.logger.error(`Failed to resize ${pvcName}: ${e.message}`);
return {
success: false,
message: e.body?.message || e.message || `Failed to resize ${label} storage`,
};
}
}
async resizeRedisStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
if (!app.enableRedis) {
return {
success: false,
message: 'Redis is not enabled for this application',
};
}
return this.resizeNamedPvc(app, `${app.name}-redis-data`, newSize, 'Redis');
}
async resizeRabbitmqStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
if (!app.enableRabbitmq) {
return {
success: false,
message: 'RabbitMQ is not enabled for this application',
};
}
return this.resizeNamedPvc(app, `${app.name}-rabbitmq-data`, newSize, 'RabbitMQ');
}
/**
* Resize app storage PVC (all app types).
*/
async resizeAppStoragePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.k8sClientService.getK8sClient(app.clusterId);
const namespace = this.getUserNamespace(app.userId);
// Try new unified name first, then legacy wp-content name
let pvcName = `${app.name}-storage`;
let pvcResponse;
try {
pvcResponse = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
} catch {
// Fallback to legacy WordPress PVC name
pvcName = `${app.name}-wp-content`;
pvcResponse = await coreApi.readNamespacedPersistentVolumeClaim({
name: pvcName,
namespace,
});
}
try {
const currentSize = pvcResponse.spec?.resources?.requests?.storage || '2Gi';
// Parse sizes for comparison
const parseGi = (s: string) => parseInt(s.replace(/Gi$/i, ''), 10) || 0;
if (parseGi(newSize) <= parseGi(currentSize)) {
return {
success: false,
message: `Cannot shrink storage. Current: ${currentSize}, Requested: ${newSize}`,
};
}
// Patch PVC to expand
await coreApi.patchNamespacedPersistentVolumeClaim(
{
name: pvcName,
namespace,
body: { spec: { resources: { requests: { storage: newSize } } } },
},
k8s.setHeaderOptions('Content-Type', 'application/merge-patch+json'),
);
this.logger.log(`Expanded ${pvcName} from ${currentSize} to ${newSize}`);
return {
success: true,
message: `App storage expanded from ${currentSize} to ${newSize}`,
};
} catch (e: any) {
this.logger.error(`Failed to resize ${pvcName}: ${e.message}`);
return {
success: false,
message: e.body?.message || e.message || 'Failed to resize app storage',
};
}
}
// ─── 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.
*
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
*/
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 = this.getUserNamespace(app.userId);
const dbName = `${app.name}-db`;
const jobName = `${app.name}-db-dump-${Date.now()}`;
// 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',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 180,
activeDeadlineSeconds: 1200,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'dump',
image,
command,
env: [
{
name: 'DB_USER',
valueFrom: {
secretKeyRef: {
name: `${app.name}-db-secret`,
key: 'username',
},
},
},
{
name: 'DB_PASSWORD',
valueFrom: {
secretKeyRef: {
name: `${app.name}-db-secret`,
key: 'password',
},
},
},
],
volumeMounts: [{ name: 'dump-vol', mountPath: '/dump' }],
resources: {
requests: { cpu: '100m', memory: '128Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
},
],
volumes: [{ name: 'dump-vol', emptyDir: {} }],
},
},
},
};
try {
await batchApi.createNamespacedJob({ namespace, body: job });
} catch (e: any) {
this.logger.error(`Failed to create DB dump job: ${e.message}`);
return { data: null, logs: `Failed to create dump job: ${e.message}` };
}
// Wait for dump to complete (check logs for DUMP_DONE marker)
const timeout = 900_000;
const start = Date.now();
let dumpDone = false;
let podName: string | undefined;
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,
labelSelector: `job-name=${jobName}`,
});
if (pods.items.length > 0) {
podName = pods.items[0].metadata?.name;
const phase = pods.items[0].status?.phase;
// Check if pod is Running (container is in sleep phase after dump)
if (podName && phase === 'Running') {
try {
const logRes = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
container: 'dump',
follow: false,
tailLines: 50,
});
if (logRes?.includes('DUMP_DONE')) {
dumpDone = true;
onProgress?.(88);
break;
}
} catch {}
}
// If Failed, exit early
if (phase === 'Failed') break;
}
} catch {}
}
let dumpBuffer: Buffer | null = null;
let logs = '';
if (dumpDone && podName) {
try {
// Use kubectl cp equivalent via Exec with proper streams
const exec = new k8s.Exec(kc);
const chunks: Buffer[] = [];
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
stdoutStream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stderrStream.on('data', (chunk: Buffer) => {
logs += chunk.toString();
});
await new Promise<void>((resolve, reject) => {
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'));
});
});
if (chunks.length > 0) {
dumpBuffer = Buffer.concat(chunks);
onProgress?.(95);
this.logger.log(`DB dump retrieved: ${dumpBuffer.length} bytes`);
}
} catch (e: any) {
this.logger.warn(`Exec failed for DB dump: ${e.message}`);
logs = e.message;
}
}
// Cleanup: delete the job early to free resources
try {
await batchApi.deleteNamespacedJob({
name: jobName,
namespace,
propagationPolicy: 'Background',
});
} catch {}
if (!dumpBuffer) {
return {
data: null,
logs: logs || 'Dump failed or could not be retrieved',
};
}
return { data: dumpBuffer, logs: 'OK' };
}
/**
* Archive the wp-content directory from a WordPress app's PVC via a K8s Job.
* The job creates a tar.gz of /var/www/html/wp-content and we retrieve it via exec.
*
* Strategy: Create archive, then sleep to allow exec retrieval.
*/
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 = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-archive-${Date.now()}`;
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 180,
activeDeadlineSeconds: 1200,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'archiver',
image: 'alpine:3.19',
command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && cd /wp-content && tar czf /output/wp-content.tar.gz . && echo "ARCHIVE_DONE" && sleep 900'],
volumeMounts: [
{
name: 'wp-content',
mountPath: '/wp-content',
readOnly: true,
},
{ name: 'output', mountPath: '/output' },
],
resources: {
requests: { cpu: '100m', memory: '64Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
},
],
volumes: [
{
name: 'wp-content',
persistentVolumeClaim: { claimName: pvcName },
},
{ name: 'output', emptyDir: {} },
],
},
},
},
};
try {
await batchApi.createNamespacedJob({ namespace, body: job });
} catch (e: any) {
this.logger.error(`Failed to create wp-content archive job: ${e.message}`);
return { data: null, logs: `Failed to create archive job: ${e.message}` };
}
// Wait for archive to complete (check logs for ARCHIVE_DONE marker)
const timeout = 900_000;
const start = Date.now();
let archiveDone = false;
let podName: string | undefined;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
if (pods.items.length > 0) {
podName = pods.items[0].metadata?.name;
const phase = pods.items[0].status?.phase;
if (podName && phase === 'Running') {
try {
const logRes = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
container: 'archiver',
follow: false,
tailLines: 50,
});
if (logRes?.includes('ARCHIVE_DONE')) {
archiveDone = true;
break;
}
} catch {}
}
if (phase === 'Failed') break;
}
} catch {}
}
let archiveBuffer: Buffer | null = null;
let logs = '';
if (archiveDone && podName) {
try {
const exec = new k8s.Exec(kc);
const chunks: Buffer[] = [];
const stdoutStream = new PassThrough();
const stderrStream = new PassThrough();
stdoutStream.on('data', (chunk: Buffer) => {
chunks.push(chunk);
});
stderrStream.on('data', (chunk: Buffer) => {
logs += chunk.toString();
});
await new Promise<void>((resolve, reject) => {
exec.exec(namespace, podName!, 'archiver', ['cat', '/output/wp-content.tar.gz'], stdoutStream, stderrStream, null, false, (status: k8s.V1Status) => {
if (status.status === 'Success') resolve();
else reject(new Error(status.message || 'exec failed'));
});
});
if (chunks.length > 0) {
archiveBuffer = Buffer.concat(chunks);
this.logger.log(`wp-content archive retrieved: ${archiveBuffer.length} bytes`);
}
} catch (e: any) {
this.logger.warn(`Exec failed for wp-content archive: ${e.message}`);
logs = e.message;
}
}
// Cleanup
try {
await batchApi.deleteNamespacedJob({
name: jobName,
namespace,
propagationPolicy: 'Background',
});
} catch {}
if (!archiveBuffer) {
return {
data: null,
logs: logs || 'Archive failed or could not be retrieved',
};
}
return { data: archiveBuffer, logs: 'OK' };
}
/**
* 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 namespace = this.getUserNamespace(app.userId);
const pvcName = `${app.name}-storage`;
const ts = Date.now();
const helperPodName = `${app.name}-wp-restore-${ts}`;
const helperPod: k8s.V1Pod = {
apiVersion: 'v1',
kind: 'Pod',
metadata: { name: helperPodName, namespace },
spec: {
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',
},
};
const tmpArchive = path.join(os.tmpdir(), `wp-content-restore-${ts}.tar.gz`);
const tmpKubeconfig = path.join(os.tmpdir(), `kubeconfig-wprestore-${ts}.yaml`);
try {
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));
}
await execFileAsync(
'kubectl',
['--kubeconfig', tmpKubeconfig, 'cp', tmpArchive, `${namespace}/${helperPodName}:/tmp/wp-content.tar.gz`, '--retries', '3'],
{ maxBuffer: 50 * 1024 * 1024, timeout: 600_000 },
);
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 ─────────────────────
/**
* Get the list of Helm release revisions for an application.
* Returns up to 10 revisions sorted newest-first.
*/
async getDeploymentRevisions(app: Application): Promise<{
revisions: Array<{
revision: number;
image: string;
changeCause: string;
createdAt: string;
replicas: number;
isCurrent: boolean;
}>;
currentRevision: number;
}> {
const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name;
try {
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
const helmRevisions = await this.helmService.history(releaseName, namespace, kubeconfig);
if (!helmRevisions || helmRevisions.length === 0) {
return { revisions: [], currentRevision: 0 };
}
// The last "deployed" revision is the current one
const currentRev = helmRevisions.filter((r) => r.status === 'deployed').sort((a, b) => b.revision - a.revision)[0];
const currentRevision = currentRev ? currentRev.revision : 0;
const revisions = helmRevisions
.sort((a, b) => b.revision - a.revision)
.slice(0, 10)
.map((r) => ({
revision: r.revision,
image: '', // Helm history doesn't expose image; frontend will use description
changeCause: r.description || `${r.status}${r.chart}`,
createdAt: r.updated,
replicas: 0,
isCurrent: r.revision === currentRevision,
}));
return { revisions, currentRevision };
} catch (e: any) {
this.logger.warn(`Could not get Helm history for ${releaseName}: ${e.message}`);
return { revisions: [], currentRevision: 0 };
}
}
/**
* Rollback a Helm release to a specific revision.
*/
async rollbackDeploymentRevision(app: Application, targetRevision: number): Promise<{ success: boolean; message: string }> {
const namespace = this.getUserNamespace(app.userId);
const releaseName = app.name;
try {
const kubeconfig = await this.k8sClientService.getKubeconfig(app.clusterId);
await this.helmService.rollback(releaseName, targetRevision, namespace, kubeconfig);
this.logger.log(`Rolled back ${releaseName} to Helm revision ${targetRevision}`);
return {
success: true,
message: `Rolled back to Helm revision ${targetRevision}`,
};
} catch (e: any) {
this.logger.error(`Helm rollback failed for ${releaseName}: ${e.message}`);
return { success: false, message: e.message };
}
}
private generatePassword(length = 24): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let password = '';
for (let i = 0; i < length; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}
}