Files
cloud-host/backend/src/kubernetes/kubernetes.service.ts
T
keyhan f481a57d8f fix(snapshots): add sleep after dump/archive for exec retrieval
- Job containers now sleep 120s after completing dump/archive
- This allows exec to retrieve files before container exits
- Wait for DUMP_DONE/ARCHIVE_DONE marker before attempting exec
- Cleanup job immediately after retrieval
- Fix Helm registry-pull-secret ownership conflict with lookup
2026-04-22 18:49:03 +03:30

1725 lines
60 KiB
TypeScript

import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs';
import * as path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime, DatabaseType } from '../common/enums';
import { HelmService } from './helm.service';
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;
}
@Injectable()
export class KubernetesService implements OnModuleInit {
private readonly logger = new Logger(KubernetesService.name);
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
private helmService: HelmService,
) {}
onModuleInit() {
// Helm chart is used for deployments — no local template loading needed
}
private async getK8sClient(clusterId?: string): Promise<{
coreApi: k8s.CoreV1Api;
appsApi: k8s.AppsV1Api;
networkingApi: k8s.NetworkingV1Api;
kc: k8s.KubeConfig;
}> {
const cluster = clusterId
? await this.clustersService.findOne(clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
return {
coreApi: kc.makeApiClient(k8s.CoreV1Api),
appsApi: kc.makeApiClient(k8s.AppsV1Api),
networkingApi: kc.makeApiClient(k8s.NetworkingV1Api),
kc,
};
}
/**
* Get the raw kubeconfig string for a cluster.
*/
private async getKubeconfig(clusterId?: string): Promise<string> {
const cluster = clusterId
? await this.clustersService.findOne(clusterId)
: await this.clustersService.getDefault();
return cluster.kubeconfig;
}
/**
* Build Helm values object from an Application entity and image URI.
*/
private buildHelmValues(app: Application, imageUri: string): Record<string, any> {
const domain = this.configService.get('platform.domain');
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
const isWordPress = app.runtime === AppRuntime.WORDPRESS;
const hasDb = app.databaseType !== DatabaseType.NONE;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const values: Record<string, any> = {
app: {
name: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
runtime: app.runtime,
image: imageUri,
port: app.port,
replicas: app.replicas,
},
resources: {
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
},
envVars: app.envVars || {},
ingress: {
enabled: true,
subdomain: app.subdomain || app.name,
domain: domain,
clusterIssuer: 'letsencrypt-prod',
},
registry: {
url: pullRegistryUrl,
},
database: {
enabled: hasDb,
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
storageSize: app.dbStorageSize || '1Gi',
resources: {
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '256Mi',
memoryLimit: '512Mi',
},
},
wordpress: {
enabled: isWordPress,
wpContentStorageSize: '2Gi',
},
changeCause: `Deploy ${imageUri} at ${new Date().toISOString()}`,
};
return values;
}
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> {
// Try Helm first, fall back to direct K8s API if Helm is unavailable
try {
return await this.deployViaHelm(app, imageUri);
} 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, imageUri);
}
}
// ── Helm-based deployment ─────────────────────────────────────────
private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const values = this.buildHelmValues(app, imageUri);
const namespace = values.app.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 };
}
// ── Direct K8s API deployment (fallback) ──────────────────────────
private async deployViaK8sApi(app: Application, imageUri: string): Promise<Record<string, any>> {
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const domain = this.configService.get('platform.domain');
const context: ManifestContext = {
appName: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
image: imageUri,
port: app.port,
replicas: app.replicas,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: app.envVars || {},
runtime: app.runtime,
databaseType: app.databaseType,
domain: domain,
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
};
const manifests: Record<string, any> = {};
try {
// 1. Ensure namespace exists
await this.ensureNamespace(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 Create wp-content PVC for WordPress
if (context.runtime === AppRuntime.WORDPRESS) {
manifests.wpContentPvc = await this.applyWordPressPvc(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
manifests.ingress = await this.applyIngress(networkingApi, context);
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(namespace);
} catch {
await coreApi.createNamespace({
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(`${ctx.appName}-env`, ctx.namespace, secret);
} catch {
await coreApi.createNamespacedSecret(ctx.namespace, 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) {
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, '_')}` },
);
}
// 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: {
containers: [
{
name: ctx.appName,
image: ctx.image,
imagePullPolicy: 'Always',
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,
},
...(ctx.runtime === AppRuntime.WORDPRESS
? {
volumeMounts: [
{ name: 'wp-content', mountPath: '/var/www/html/wp-content' },
],
}
: {}),
},
],
...(ctx.runtime === AppRuntime.WORDPRESS
? {
volumes: [
{
name: 'wp-content',
persistentVolumeClaim: { claimName: `${ctx.appName}-wp-content` },
},
],
}
: {}),
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment(ctx.appName, ctx.namespace, deployment);
} catch {
await appsApi.createNamespacedDeployment(ctx.namespace, deployment);
}
return deployment;
}
private async applyWordPressPvc(coreApi: k8s.CoreV1Api, ctx: ManifestContext): Promise<any> {
const pvcName = `${ctx.appName}-wp-content`;
const pvc = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: pvcName, namespace: ctx.namespace, labels: { app: ctx.appName } },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: '2Gi' } },
},
};
try {
await coreApi.readNamespacedPersistentVolumeClaim(pvcName, ctx.namespace);
this.logger.log(`PVC ${pvcName} already exists, skipping`);
} catch {
await coreApi.createNamespacedPersistentVolumeClaim(ctx.namespace, pvc);
this.logger.log(`Created WordPress PVC: ${pvcName}`);
}
return pvc;
}
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(ctx.appName, ctx.namespace, service);
} catch {
await coreApi.createNamespacedService(ctx.namespace, service);
}
return service;
}
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise<any> {
const host = `${ctx.subdomain}.${ctx.domain}`;
const ingress: k8s.V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: ctx.appName,
namespace: ctx.namespace,
annotations: {
'cert-manager.io/cluster-issuer': 'letsencrypt-prod',
},
},
spec: {
ingressClassName: 'nginx',
rules: [
{
host,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
},
],
tls: [{ hosts: [host], secretName: `${ctx.appName}-tls` }],
},
};
try {
await networkingApi.replaceNamespacedIngress(ctx.appName, ctx.namespace, ingress);
} catch {
await networkingApi.createNamespacedIngress(ctx.namespace, 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
const isPostgres = ctx.databaseType === DatabaseType.POSTGRESQL;
const defaultDbVersion = isPostgres ? '16' : '8.0';
const dbVer = ctx.dbVersion || defaultDbVersion;
const image = isPostgres ? `postgres:${dbVer}-alpine` : `mysql:${dbVer}`;
const port = isPostgres ? 5432 : 3306;
const dataPath = isPostgres ? '/var/lib/postgresql/data' : '/var/lib/mysql';
const envVars = isPostgres
? [
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
{ name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') },
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' } } },
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
]
: [
{ 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' } } },
];
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: '100m', memory: '256Mi' },
limits: { cpu: '500m', memory: '512Mi' },
},
readinessProbe: isPostgres
? { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 }
: { exec: { command: ['mysqladmin', 'ping', '-h', '127.0.0.1'] }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 },
livenessProbe: isPostgres
? { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 }
: { exec: { command: ['mysqladmin', 'ping', '-h', '127.0.0.1'] }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 },
},
],
volumes: [
{ name: 'db-storage', persistentVolumeClaim: { claimName: dbName } },
],
},
},
},
};
try {
await appsApi.replaceNamespacedDeployment(dbName, ctx.namespace, dbDeployment);
} catch {
await appsApi.createNamespacedDeployment(ctx.namespace, 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(dbName, ctx.namespace, dbService);
} catch {
await coreApi.createNamespacedService(ctx.namespace, 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(`${appName}-db-secret`, namespace, secret);
} catch {
await coreApi.createNamespacedSecret(namespace, secret);
}
}
private async createPVC(
coreApi: k8s.CoreV1Api,
namespace: string,
name: string,
size: string,
): Promise<void> {
const pvc: k8s.V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: size } },
},
};
try {
await coreApi.readNamespacedPersistentVolumeClaim(name, namespace);
// PVC exists, don't recreate
} catch {
await coreApi.createNamespacedPersistentVolumeClaim(namespace, pvc);
}
}
async getPodLogs(app: Application): Promise<string> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pods = await coreApi.listNamespacedPod(
namespace,
undefined,
undefined,
undefined,
undefined,
`app=${app.name}`,
);
if (pods.body.items.length === 0) {
return 'No pods found for this application.';
}
const podName = pods.body.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
const logResponse = await coreApi.readNamespacedPodLog(
podName,
namespace,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
200,
);
return logResponse.body;
}
async scaleDeployment(app: Application, replicas: number): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
await appsApi.patchNamespacedDeployment(
app.name,
namespace,
{ spec: { replicas } },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
}
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
await appsApi.patchNamespacedDeployment(
app.name,
namespace,
{
spec: {
template: {
metadata: {
annotations: {
'kubectl.kubernetes.io/restartedAt': new Date().toISOString(),
},
},
},
},
},
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/merge-patch+json' } },
);
}
/**
* Get real-time resource usage (CPU/Memory) for an app's pods via metrics-server.
* Also returns the configured requests/limits and pod status.
*/
async getResourceUsage(app: Application): Promise<any> {
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
// Get deployment info for configured resources
let deployment: k8s.V1Deployment | null = null;
try {
const depResponse = await appsApi.readNamespacedDeployment(app.name, namespace);
deployment = depResponse.body;
} catch {
// Deployment may not exist yet
}
// Get pods
const podsResponse = await coreApi.listNamespacedPod(
namespace,
undefined,
undefined,
undefined,
undefined,
`app=${app.name}`,
);
const pods = podsResponse.body.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?.[0]?.restartCount || 0,
startedAt: pod.status?.startTime,
}));
// Try to get metrics from metrics-server via custom API
let podMetrics: any[] = [];
try {
const metricsClient = new k8s.CustomObjectsApi(kc.getCurrentCluster()?.server);
// Use the kc to make a raw request to metrics API
const opts: any = {};
await kc.applyToRequest(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);
podMetrics = await new Promise((resolve) => {
const client = url.protocol === 'https:' ? https : http;
const reqOpts: any = {
hostname: url.hostname,
port: url.port,
path: url.pathname + `?labelSelector=app%3D${app.name}`,
method: 'GET',
headers: opts.headers || {},
rejectUnauthorized: false,
};
// Apply TLS from kubeconfig
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,
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 {
this.logger.warn(`Metrics not available for ${app.name}`);
}
// Get configured resources from deployment
const container = deployment?.spec?.template?.spec?.containers?.[0];
const configured = {
cpuRequest: container?.resources?.requests?.cpu || app.cpuRequest,
cpuLimit: container?.resources?.limits?.cpu || app.cpuLimit,
memoryRequest: container?.resources?.requests?.memory || app.memoryRequest,
memoryLimit: container?.resources?.limits?.memory || app.memoryLimit,
replicas: deployment?.spec?.replicas ?? app.replicas,
readyReplicas: deployment?.status?.readyReplicas || 0,
availableReplicas: deployment?.status?.availableReplicas || 0,
};
return {
configured,
pods,
metrics: podMetrics,
};
}
/**
* Update resource limits/requests and replicas on a live K8s deployment.
*/
async updateResources(
app: Application,
resources: { cpuRequest?: string; cpuLimit?: string; memoryRequest?: string; memoryLimit?: string; replicas?: number },
): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const patch: any = { spec: {} };
if (resources.replicas !== undefined) {
patch.spec.replicas = resources.replicas;
}
if (resources.cpuRequest || resources.cpuLimit || resources.memoryRequest || resources.memoryLimit) {
patch.spec.template = {
spec: {
containers: [
{
name: app.name,
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(
app.name,
namespace,
patch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(resources)}`);
}
/**
* Get preview info for a deployed application.
* Patches the service to NodePort if needed, and returns the access URL.
*/
async getPreviewInfo(app: Application): Promise<{
url: string;
nodePort: number;
host: string;
ingressUrl?: string;
}> {
const { coreApi, networkingApi, kc } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const domain = this.configService.get('platform.domain');
const clusterServer = kc.getCurrentCluster()?.server || '';
// Extract host IP from cluster API server URL (e.g., https://217.197.107.252:6443 → 217.197.107.252)
let hostIp = '127.0.0.1';
try {
const serverUrl = new URL(clusterServer);
hostIp = serverUrl.hostname;
} catch {}
// Read current service
let nodePort = 0;
try {
const svcResponse = await coreApi.readNamespacedService(app.name, namespace);
const svc = svcResponse.body;
if (svc.spec?.type === 'NodePort') {
// Already NodePort, read the assigned port
nodePort = svc.spec.ports?.[0]?.nodePort || 0;
} else {
// Patch ClusterIP → NodePort so we can access from outside
const patchBody = {
spec: {
type: 'NodePort',
ports: [
{
port: 80,
targetPort: app.port,
protocol: 'TCP',
},
],
},
};
const patchedResponse = await coreApi.patchNamespacedService(
app.name,
namespace,
patchBody,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
nodePort = patchedResponse.body.spec?.ports?.[0]?.nodePort || 0;
this.logger.log(`Patched service ${app.name} to NodePort: ${nodePort}`);
}
} catch (e: any) {
this.logger.warn(`Failed to get/patch service for ${app.name}: ${e.message}`);
throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`);
}
// Build ingress URL
const subdomain = app.subdomain || app.name;
const ingressUrl = `https://${subdomain}.${domain}`;
return {
url: `http://${hostIp}:${nodePort}`,
nodePort,
host: hostIp,
ingressUrl,
};
}
async deleteApplication(app: Application): Promise<void> {
const namespace = `user-${app.userId.split('-')[0]}`;
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
// Step 1: Try Helm uninstall (handles most resources)
try {
const kubeconfig = await this.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(app.name, namespace); } catch {}
try { await coreApi.deleteNamespacedService(app.name, namespace); } catch {}
try { await networkingApi.deleteNamespacedIngress(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(dbName, namespace),
() => coreApi.deleteNamespacedService(dbName, namespace),
() => coreApi.deleteNamespacedPersistentVolumeClaim(dbName, namespace),
() => coreApi.deleteNamespacedSecret(`${app.name}-db-secret`, namespace),
// WordPress wp-content PVC
() => coreApi.deleteNamespacedPersistentVolumeClaim(`${app.name}-wp-content`, namespace),
// App env secret
() => coreApi.deleteNamespacedSecret(`${app.name}-env`, namespace),
// Registry pull secret (shared, but labeled per-app — safe to delete)
() => coreApi.deleteNamespacedSecret('registry-pull-secret', namespace),
// TLS secret created by cert-manager
() => coreApi.deleteNamespacedSecret(`${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}`);
}
/**
* 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.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
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, undefined, undefined, undefined, undefined, `app=${dbLabel}`,
);
for (const pod of pods.body.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`);
}
/**
* 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.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const dbName = `${app.name}-db`;
const ts = Date.now();
const pvcName = `${app.name}-db-dump-${ts}`;
const helperPodName = `${pvcName}-helper`;
const jobName = `${app.name}-db-restore-${ts}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const dumpSize = fs.statSync(dumpFilePath).size;
const pvcSizeGi = Math.max(1, Math.ceil((dumpSize * 2) / (1024 * 1024 * 1024)));
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, {
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, helperPod);
// Wait for helper pod Running
const podTimeout = 120_000;
const podStart = Date.now();
while (Date.now() - podStart < podTimeout) {
const pod = await coreApi.readNamespacedPod(helperPodName, namespace);
if (pod.body.status?.phase === 'Running') break;
if (pod.body.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(helperPodName, namespace); } catch {}
}
// ── 4. Build restore command ──
const command = isPostgres
? [
'sh', '-c',
`PGPASSWORD="$DB_PASSWORD" psql -h ${dbName} -U "$DB_USER" -d ${dbDatabase} -f /dump/dump.sql 2>&1`,
]
: [
'sh', '-c',
`mysql -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} < /dump/dump.sql 2>&1`,
];
const defaultDbVer = isPostgres ? '16' : '8.0';
const restoreDbVer = app.dbVersion || defaultDbVer;
const image = isPostgres ? `postgres:${restoreDbVer}-alpine` : `mysql:${restoreDbVer}`;
// ── 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, job);
this.logger.log(`Created DB restore job ${jobName} for ${app.name}`);
} catch (e: any) {
try { await coreApi.deleteNamespacedPersistentVolumeClaim(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(jobName, namespace);
const status = jobStatus.body.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, undefined, undefined, undefined, undefined, `job-name=${jobName}`,
);
if (pods.body.items.length > 0) {
const podName = pods.body.items[0].metadata?.name;
if (podName) {
const logResponse = await coreApi.readNamespacedPodLog(podName, namespace);
logs = logResponse.body || '';
}
}
} catch (e: any) {
this.logger.warn(`Could not get restore job logs: ${e.message}`);
}
// ── 8. Clean up the dump PVC ──
try {
await coreApi.deleteNamespacedPersistentVolumeClaim(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 };
}
/**
* Resize (expand) the database PVC for an application.
* K8s only supports PVC expansion, not shrinking.
*/
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-db`;
try {
// Read current PVC to check current size
const currentPvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
const currentSize = currentPvc.body.spec?.resources?.requests?.storage || '1Gi';
const currentGi = parseInt(currentSize.replace('Gi', ''), 10) || 1;
const newGi = parseInt(newSize.replace('Gi', ''), 10) || 1;
if (newGi <= currentGi) {
return { success: false, message: `New size (${newSize}) must be larger than current size (${currentSize})` };
}
// Patch PVC to expand
const patch = [
{
op: 'replace',
path: '/spec/resources/requests/storage',
value: newSize,
},
];
await coreApi.patchNamespacedPersistentVolumeClaim(
pvcName,
namespace,
patch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/json-patch+json' } },
);
this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`);
return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` };
} 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.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-db`;
const pvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
return pvc.body.spec?.resources?.requests?.storage || app.dbStorageSize || '1Gi';
} catch {
return app.dbStorageSize || '1Gi';
}
}
// ─── Snapshot helpers ───────────────────────────────
/**
* 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): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const dbName = `${app.name}-db`;
const jobName = `${app.name}-db-dump-${Date.now()}`;
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const dbDatabase = app.name.replace(/-/g, '_');
const defaultDbVer = isPostgres ? '16' : '8.0';
const dbVer = app.dbVersion || defaultDbVer;
const image = isPostgres ? `postgres:${dbVer}-alpine` : `mysql:${dbVer}`;
// Dump command writes to /dump/output.sql, then sleeps to allow exec retrieval
const command = isPostgres
? ['sh', '-c', `PGPASSWORD="$DB_PASSWORD" pg_dump -h ${dbName} -U "$DB_USER" -d ${dbDatabase} --no-owner --no-acl > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 120`]
: ['sh', '-c', `mysqldump -h ${dbName} -u "$DB_USER" -p"$DB_PASSWORD" ${dbDatabase} > /dump/output.sql 2>/tmp/err; cat /tmp/err >&2; echo "DUMP_DONE"; sleep 120`];
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 180,
activeDeadlineSeconds: 300,
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, 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 = 300_000;
const start = Date.now();
let dumpDone = false;
let podName: string | undefined;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0) {
podName = pods.body.items[0].metadata?.name;
const phase = pods.body.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(podName, namespace, 'dump', false, undefined, undefined, undefined, undefined, undefined, 50);
if (logRes.body?.includes('DUMP_DONE')) {
dumpDone = true;
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
const exec = new k8s.Exec(kc);
const chunks: Buffer[] = [];
await new Promise<void>((resolve, reject) => {
exec.exec(
namespace, podName!, 'dump',
['cat', '/dump/output.sql'],
{
write: (data: string) => { chunks.push(Buffer.from(data, 'binary')); },
} as any,
null,
{
write: (data: string) => { logs += data; },
} as any,
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);
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(jobName, namespace, undefined, undefined, undefined, undefined, '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.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-wp-content`;
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: 300,
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 120'],
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, 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 = 300_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, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0) {
podName = pods.body.items[0].metadata?.name;
const phase = pods.body.items[0].status?.phase;
if (podName && phase === 'Running') {
try {
const logRes = await coreApi.readNamespacedPodLog(podName, namespace, 'archiver', false, undefined, undefined, undefined, undefined, undefined, 50);
if (logRes.body?.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[] = [];
await new Promise<void>((resolve, reject) => {
exec.exec(
namespace, podName!, 'archiver',
['cat', '/output/wp-content.tar.gz'],
{
write: (data: string) => { chunks.push(Buffer.from(data, 'binary')); },
} as any,
null,
{
write: (data: string) => { logs += data; },
} as any,
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(jobName, namespace, undefined, undefined, undefined, undefined, '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.
*/
async restoreWpContent(app: Application, archiveBuffer: Buffer): Promise<{ success: boolean; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-wp-content`;
const jobName = `${app.name}-wp-restore-${Date.now()}`;
const secretName = `${jobName}-archive`;
// Store archive in a secret
const archiveSecret = {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: secretName, namespace },
data: { 'wp-content.tar.gz': archiveBuffer.toString('base64') },
};
try {
await coreApi.createNamespacedSecret(namespace, archiveSecret);
} catch (e: any) {
return { success: false, logs: `Failed to create archive secret: ${e.message}` };
}
const job: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 120,
backoffLimit: 0,
template: {
spec: {
restartPolicy: 'Never',
containers: [{
name: 'restore',
image: 'alpine:3.19',
command: ['sh', '-c', 'apk add --no-cache tar gzip > /dev/null 2>&1 && rm -rf /wp-content/* && cd /wp-content && tar xzf /archive/wp-content.tar.gz && echo "RESTORE_DONE"'],
volumeMounts: [
{ name: 'wp-content', mountPath: '/wp-content' },
{ name: 'archive', mountPath: '/archive', readOnly: true },
],
resources: { requests: { cpu: '100m', memory: '64Mi' }, limits: { cpu: '500m', memory: '256Mi' } },
}],
volumes: [
{ name: 'wp-content', persistentVolumeClaim: { claimName: pvcName } },
{ name: 'archive', secret: { secretName } },
],
},
},
},
};
try {
await batchApi.createNamespacedJob(namespace, job);
} catch (e: any) {
try { await coreApi.deleteNamespacedSecret(secretName, namespace); } catch {}
return { success: false, logs: `Failed to create restore job: ${e.message}` };
}
// Wait
const timeout = 300_000;
const start = Date.now();
let succeeded = false;
let failed = false;
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
try {
const st = await batchApi.readNamespacedJob(jobName, namespace);
if (st.body.status?.succeeded && st.body.status.succeeded > 0) { succeeded = true; break; }
if (st.body.status?.failed && st.body.status.failed > 0) { failed = true; break; }
} catch {}
}
let logs = '';
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0 && pods.body.items[0].metadata?.name) {
const logRes = await coreApi.readNamespacedPodLog(pods.body.items[0].metadata.name, namespace);
logs = logRes.body || '';
}
} catch {}
try { await coreApi.deleteNamespacedSecret(secretName, namespace); } catch {}
return { success: succeeded && !failed, logs: logs || (succeeded ? 'Restore completed' : 'Restore failed or timed out') };
}
// ─── 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 = `user-${app.userId.split('-')[0]}`;
const releaseName = app.name;
try {
const kubeconfig = await this.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 = `user-${app.userId.split('-')[0]}`;
const releaseName = app.name;
try {
const kubeconfig = await this.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;
}
}