fix: reliable source upload, build cancel, WordPress port 80 default

- Replace port-forward/netcat PVC upload with kubectl cp for integrity
- Add build cancellation API and session cleanup; deploy catches cancel
- Default port 80 for WordPress, PHP, and Laravel on create
- Build progress modal with cancel; Helm/K8s adjustments for deployments
- Update build and kubernetes specs

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-14 16:03:45 +03:30
parent 3d56a2cc5d
commit 0c0a6cd5be
12 changed files with 637 additions and 93 deletions
@@ -231,11 +231,6 @@ spec:
- name: app-storage
persistentVolumeClaim:
claimName: {{ $name }}-storage
{{- if .Values.wordpress.enabled }}
- name: wp-content
persistentVolumeClaim:
claimName: {{ $name }}-wp-content
{{- end }}
{{- if .Values.elasticsearch.enabled }}
- name: app-logs
emptyDir: {}
@@ -1,19 +0,0 @@
{{- if .Values.wordpress.enabled }}
{{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ $name }}-wp-content
namespace: {{ $ns }}
labels:
{{- include "cloudhost-app.labels" . | nindent 4 }}
annotations:
"helm.sh/resource-policy": keep
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: {{ .Values.wordpress.wpContentStorageSize | quote }}
{{- end }}
-1
View File
@@ -51,7 +51,6 @@ database:
# ── WordPress-specific ───────────────────────────────────
wordpress:
enabled: false
wpContentStorageSize: "2Gi"
# ── Redis ──────────────────────────────────────────────
redis:
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
import { Application } from './entities/application.entity';
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
import { ClustersService } from '../clusters/clusters.service';
import { UserRole, DatabaseType, CustomDomainStatus } from '../common/enums';
import { UserRole, DatabaseType, CustomDomainStatus, AppRuntime } from '../common/enums';
@Injectable()
export class ApplicationsService {
@@ -78,6 +78,10 @@ export class ApplicationsService {
const customDomain = dto.customDomain?.toLowerCase().trim() || undefined;
const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(dto.runtime)
? 80
: 3000;
const app = this.appsRepository.create({
...dto,
userId,
@@ -85,6 +89,7 @@ export class ApplicationsService {
poolId,
dbUsername,
dbPassword,
port: dto.port ?? defaultPort,
subdomain: `${dto.name}-${userId.split('-')[0]}`,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
+8 -15
View File
@@ -402,28 +402,20 @@ describe('Helper pod PVC race condition', () => {
});
describe('WordPress entrypoint script', () => {
// The generated entrypoint script content
const entrypointScript = `#!/bin/bash
set -e
# Merge user wp-content into PVC (first run only)
if [ -d /usr/src/wordpress-user/wp-content ] && [ ! -f /var/www/html/wp-content/.user-content-merged ]; then
# Merge user wp-content into PVC
if [ -d /usr/src/wordpress-user/wp-content ]; then
mkdir -p /var/www/html/wp-content
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
touch /var/www/html/wp-content/.user-content-merged
chown -R www-data:www-data /var/www/html/wp-content
fi
# Apply user wp-config.php if docker-entrypoint has not created one yet
if [ -f /usr/src/wordpress-user/wp-config.php ] && [ ! -f /var/www/html/wp-config.php ]; then
cp /usr/src/wordpress-user/wp-config.php /var/www/html/wp-config.php
chown www-data:www-data /var/www/html/wp-config.php
fi
exec docker-entrypoint.sh apache2-foreground`;
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
// Should appear only once — in the final exec line
expect(matches).toHaveLength(1);
});
@@ -431,12 +423,13 @@ exec docker-entrypoint.sh apache2-foreground`;
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
});
it('should merge wp-content only on first run', () => {
expect(entrypointScript).toContain('.user-content-merged');
it('should merge wp-content on every start when staged content exists', () => {
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
expect(entrypointScript).not.toContain('.user-content-merged');
});
it('should only apply user wp-config.php if no config exists', () => {
expect(entrypointScript).toContain('! -f /var/www/html/wp-config.php');
it('should not copy user wp-config.php (credentials come from env vars)', () => {
expect(entrypointScript).not.toContain('wp-config.php');
});
it('should set proper ownership after merging wp-content', () => {
+380 -40
View File
@@ -3,7 +3,8 @@ 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 { execFile, spawn, ChildProcess } from 'child_process';
import * as net from 'net';
import { promisify } from 'util';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums';
@@ -11,20 +12,207 @@ import { ClustersService } from '../clusters/clusters.service';
const execFileAsync = promisify(execFile);
export class BuildCancelledError extends Error {
constructor() {
super('Build cancelled by user');
this.name = 'BuildCancelledError';
}
}
interface ActiveBuildSession {
cancelled: boolean;
coreApi?: k8s.CoreV1Api;
batchApi?: k8s.BatchV1Api;
namespace?: string;
buildPodName?: string;
sourcePvcName?: string;
helperPodName?: string;
processes: ChildProcess[];
socket?: net.Socket;
}
export interface BuildProgress {
phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed';
percent: number;
bytesUploaded?: number;
totalBytes?: number;
message?: string;
}
@Injectable()
export class BuildService {
private readonly logger = new Logger(BuildService.name);
private readonly progressMap = new Map<string, BuildProgress>();
private readonly activeBuilds = new Map<string, ActiveBuildSession>();
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
) {}
private beginBuildSession(deploymentId: string): void {
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] });
}
private getSession(deploymentId?: string): ActiveBuildSession | undefined {
if (!deploymentId) return undefined;
return this.activeBuilds.get(deploymentId);
}
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
const session = this.activeBuilds.get(deploymentId);
if (session) Object.assign(session, update);
}
private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void {
const session = this.getSession(deploymentId);
if (!session) return;
session.processes.push(proc);
if (session.cancelled) {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
}
private registerSocket(deploymentId: string | undefined, socket: net.Socket): void {
const session = this.getSession(deploymentId);
if (!session) return;
if (session.socket) {
try { session.socket.destroy(); } catch { /* ignore */ }
}
session.socket = socket;
if (session.cancelled) {
try { socket.destroy(); } catch { /* ignore */ }
}
}
private throwIfCancelled(deploymentId?: string): void {
if (deploymentId && this.activeBuilds.get(deploymentId)?.cancelled) {
throw new BuildCancelledError();
}
}
private endBuildSession(deploymentId?: string): void {
if (deploymentId) this.activeBuilds.delete(deploymentId);
}
async cancelBuild(deploymentId: string): Promise<void> {
const session = this.activeBuilds.get(deploymentId);
if (!session) {
this.setProgress(deploymentId, { phase: 'failed', percent: 0, message: 'Cancelled by user' });
return;
}
session.cancelled = true;
this.logger.log(`Cancelling build for deployment ${deploymentId}`);
if (session.socket) {
try { session.socket.destroy(); } catch { /* ignore */ }
}
for (const proc of session.processes) {
try { proc.kill('SIGKILL'); } catch { /* ignore */ }
}
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
if (coreApi && namespace) {
const cleanup: Promise<unknown>[] = [];
if (helperPodName) {
cleanup.push(
coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0).catch(() => undefined),
);
}
if (buildPodName && batchApi) {
cleanup.push(
batchApi.deleteNamespacedJob(buildPodName, namespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined),
);
}
if (sourcePvcName) {
cleanup.push(
coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, namespace).catch(() => undefined),
);
}
if (buildPodName) {
cleanup.push(
coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, namespace).catch(() => undefined),
);
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
}
this.setProgress(deploymentId, { phase: 'failed', percent: 0, message: 'Cancelled by user' });
this.activeBuilds.delete(deploymentId);
}
/** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */
async cleanupBuildResourcesForApp(app: Application): Promise<void> {
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const prefix = `build-${app.name}-`;
const cluster = app.clusterId
? await this.clustersService.findOne(app.clusterId)
: await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const cleanup: Promise<unknown>[] = [];
const [pods, pvcs, jobs, configMaps] = await Promise.all([
coreApi.listNamespacedPod(buildNamespace),
coreApi.listNamespacedPersistentVolumeClaim(buildNamespace),
batchApi.listNamespacedJob(buildNamespace),
coreApi.listNamespacedConfigMap(buildNamespace),
]);
for (const pod of pods.body.items) {
const name = pod.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedPod(name, buildNamespace, undefined, undefined, 0).catch(() => undefined));
}
}
for (const pvc of pvcs.body.items) {
const name = pvc.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedPersistentVolumeClaim(name, buildNamespace).catch(() => undefined));
}
}
for (const job of jobs.body.items) {
const name = job.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(batchApi.deleteNamespacedJob(name, buildNamespace, undefined, undefined, 0, undefined, 'Foreground').catch(() => undefined));
}
}
for (const cm of configMaps.body.items) {
const name = cm.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedConfigMap(name, buildNamespace).catch(() => undefined));
}
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
}
getProgress(deploymentId: string): BuildProgress | null {
return this.progressMap.get(deploymentId) ?? null;
}
setProgress(deploymentId: string | undefined, progress: BuildProgress): void {
if (!deploymentId) return;
this.progressMap.set(deploymentId, progress);
}
clearProgress(deploymentId: string): void {
this.progressMap.delete(deploymentId);
}
/**
* Builds a Docker image for the application using Kaniko inside K8s.
* Returns { imageUri, buildLog } — the full image URI and the build logs.
*/
async buildImage(app: Application): Promise<{ imageUri: string; buildLog: string }> {
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
// Internal registry (used by Kaniko inside K8s for pushing)
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
// External registry URL (used by kubelet for pulling — NodePort or external)
@@ -36,12 +224,20 @@ export class BuildService {
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
if (deploymentId) {
this.beginBuildSession(deploymentId);
}
// Determine Dockerfile based on runtime
const dockerfileContent = this.generateDockerfile(app);
// Create Kaniko build pod
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
if (deploymentId) {
this.updateBuildSession(deploymentId, { buildPodName });
}
// Use the cluster's kubeconfig instead of default
const cluster = app.clusterId
? await this.clustersService.findOne(app.clusterId)
@@ -52,8 +248,13 @@ export class BuildService {
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
if (deploymentId) {
this.updateBuildSession(deploymentId, { coreApi, batchApi, namespace: buildNamespace });
}
// Ensure the build namespace exists
await this.ensureNamespace(coreApi, buildNamespace);
this.throwIfCancelled(deploymentId);
// Determine if we have uploaded code or git URL
const codePath = app.codePath ? path.resolve(app.codePath) : null;
@@ -77,12 +278,15 @@ export class BuildService {
let sourcePvcName: string | undefined;
if (hasUploadedCode) {
sourcePvcName = `${buildPodName}-source`;
if (deploymentId) {
this.updateBuildSession(deploymentId, { sourcePvcName });
}
const zipSize = fs.statSync(codePath!).size;
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
await this.uploadSourceViaPVC(
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi,
kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId,
);
}
@@ -131,11 +335,19 @@ export class BuildService {
name: 'unzip-source',
image: 'alpine:3.19',
command: ['sh', '-c', `
apk add --no-cache unzip &&
apk add --no-cache unzip tar gzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /tmp/extract &&
cd /tmp/extract &&
unzip /source-pvc/source.zip &&
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected gzip tarball" &&
tar xzf /source-pvc/source.zip
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected zip archive" &&
unzip -q /source-pvc/source.zip
else
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
fi &&
echo "--- Extracted contents ---" &&
ls -la /tmp/extract/ &&
mkdir -p /workspace-out/source &&
@@ -263,7 +475,8 @@ export class BuildService {
this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`);
// Wait for build to complete
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
this.setProgress(deploymentId, { phase: 'building', percent: 15, message: 'Building Docker image...' });
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId);
// Capture build logs on success
let buildLog = '';
@@ -274,6 +487,9 @@ export class BuildService {
this.logger.log(`Build completed successfully: ${pullImageUri}`);
return { imageUri: pullImageUri, buildLog };
} catch (error: any) {
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
throw error;
}
// Try to get build logs for debugging
let buildLog = '';
try {
@@ -301,11 +517,115 @@ export class BuildService {
} catch (e: any) {
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
}
this.endBuildSession(deploymentId);
}
}
/**
* Upload source zip to K8s via PVC + helper pod + kubectl cp.
* Poll the helper pod until netcat finishes writing and reports the expected byte count.
*/
private async waitForRemoteUploadDone(
kubeconfig: string,
namespace: string,
podName: string,
expectedSize: number,
deploymentId?: string,
): Promise<void> { /* unused — kept for API compat */ }
/**
* Upload a local file to the helper pod using kubectl cp with progress tracking.
* kubectl cp uses tar over the k8s exec API — reliable for any file size.
*/
private streamFileToHelperPod(
kubeconfig: string,
namespace: string,
podName: string,
filePath: string,
fileSize: number,
deploymentId?: string,
): Promise<void> {
const maxAttempts = 3;
const runOnce = () => new Promise<void>((resolve, reject) => {
this.throwIfCancelled(deploymentId);
const kubectl = spawn('kubectl', [
'--kubeconfig', kubeconfig,
'cp', filePath, `${namespace}/${podName}:/data/source.zip`,
'-c', 'helper',
'--retries', '3',
], { stdio: ['ignore', 'pipe', 'pipe'] });
this.registerProcess(deploymentId, kubectl);
let stderr = '';
kubectl.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
// Track progress by polling remote file size
let progressTimer: NodeJS.Timeout | undefined;
const pollProgress = () => {
execFileAsync('kubectl', [
'--kubeconfig', kubeconfig,
'exec', '-n', namespace, podName, '-c', 'helper', '--',
'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0',
], { timeout: 10_000 }).then(({ stdout }) => {
const remoteSize = parseInt(stdout.trim(), 10) || 0;
const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100));
this.setProgress(deploymentId, {
phase: 'uploading',
percent,
bytesUploaded: remoteSize,
totalBytes: fileSize,
message: `Uploading to cluster... ${percent}%`,
});
}).catch(() => { /* polling failure is non-fatal */ });
};
progressTimer = setInterval(pollProgress, 3000);
pollProgress();
kubectl.on('error', (err) => {
clearInterval(progressTimer);
reject(new Error(`kubectl cp spawn error: ${err.message}`));
});
kubectl.on('close', (code) => {
clearInterval(progressTimer);
if (code === 0) resolve();
else reject(new Error(`kubectl cp failed (code ${code}): ${stderr.trim()}`));
});
});
return (async () => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
this.throwIfCancelled(deploymentId);
if (attempt > 1) {
this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`);
await execFileAsync('kubectl', [
'--kubeconfig', kubeconfig,
'exec', '-n', namespace, podName, '-c', 'helper', '--',
'rm', '-f', '/data/source.zip',
], { timeout: 15_000 }).catch(() => undefined);
this.setProgress(deploymentId, {
phase: 'uploading',
percent: 0,
bytesUploaded: 0,
totalBytes: fileSize,
message: `Retrying upload (attempt ${attempt})...`,
});
}
await runOnce();
return;
} catch (err) {
if (err instanceof BuildCancelledError || (err as Error)?.name === 'BuildCancelledError') throw err;
if (attempt === maxAttempts) throw err;
this.logger.warn(`Upload attempt ${attempt} failed: ${(err as Error).message}`);
}
}
})();
}
/**
* Upload source zip to K8s via PVC + helper pod.
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
*/
private async uploadSourceViaPVC(
@@ -315,11 +635,16 @@ export class BuildService {
pvcName: string,
zipPath: string,
sizeGi: number,
deploymentId?: string,
): Promise<void> {
const t0 = Date.now();
const helperPodName = `${pvcName}-helper`;
const zipSize = fs.statSync(zipPath).size;
if (deploymentId) {
this.updateBuildSession(deploymentId, { helperPodName });
}
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
// 1. Create PVC
@@ -366,6 +691,7 @@ export class BuildService {
const podTimeout = 120_000; // 2 minutes
const podStart = Date.now();
while (Date.now() - podStart < podTimeout) {
this.throwIfCancelled(deploymentId);
const pod = await coreApi.readNamespacedPod(helperPodName, namespace);
const phase = pod.body.status?.phase;
if (phase === 'Running') break;
@@ -385,35 +711,44 @@ export class BuildService {
fs.writeFileSync(tmpKubeconfig, kcYaml);
try {
// 5. Copy the zip into the helper pod via kubectl cp.
// Both k8s.Exec (WebSocket) and kubectl exec -i stdin piping are unreliable
// for binary transfers — data can be lost before the connection is ready.
// kubectl cp uses tar internally and handles connection timing correctly.
// 5. Upload the zip via kubectl cp (tar-based, reliable for any size).
const t2 = Date.now();
this.setProgress(deploymentId, {
phase: 'uploading',
percent: 0,
bytesUploaded: 0,
totalBytes: zipSize,
message: 'Uploading source to cluster...',
});
await execFileAsync('kubectl', [
'--kubeconfig', tmpKubeconfig,
'cp', zipPath,
`${namespace}/${helperPodName}:/data/source.zip`,
'-c', 'helper',
], { timeout: 1200_000 });
await this.streamFileToHelperPod(
tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId,
);
this.logger.log(`[timing] kubectl cp upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
this.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
this.setProgress(deploymentId, {
phase: 'uploading',
percent: 100,
bytesUploaded: zipSize,
totalBytes: zipSize,
message: 'Upload complete, verifying...',
});
// 5b. Verify the file was written correctly
// 5b. Verify the file was written correctly (exact size)
const { stdout: sizeStr } = await execFileAsync('kubectl', [
'--kubeconfig', tmpKubeconfig,
'exec', '-n', namespace, helperPodName,
'exec', '-n', namespace, helperPodName, '-c', 'helper',
'--', 'sh', '-c', 'wc -c < /data/source.zip',
], { timeout: 30_000 });
const remoteSize = parseInt(sizeStr.trim(), 10);
if (isNaN(remoteSize) || remoteSize < zipSize * 0.95) {
if (isNaN(remoteSize) || remoteSize !== zipSize) {
throw new Error(
`Source upload incomplete: expected ${zipSize} bytes but got ${remoteSize} bytes on remote. ` +
`(${(zipSize / 1024 / 1024).toFixed(1)} MB expected, ${(remoteSize / 1024 / 1024).toFixed(1)} MB received)`,
);
}
this.logger.log(`[verify] Remote file size: ${remoteSize} bytes (expected ${zipSize}) ✓`);
} finally {
// Clean up temp kubeconfig
@@ -799,11 +1134,15 @@ RUN mkdir -p /usr/src/wordpress-user && \\
if [ -d /tmp/user-content/wp-content ]; then \\
echo ">>> Staging user wp-content (themes, plugins, uploads)..." && \\
cp -a /tmp/user-content/wp-content /usr/src/wordpress-user/wp-content; \\
elif [ -d /tmp/user-content/themes ] || [ -d /tmp/user-content/plugins ] || [ -d /tmp/user-content/uploads ]; then \\
echo ">>> Staging loose themes/plugins/uploads into wp-content..." && \\
mkdir -p /usr/src/wordpress-user/wp-content && \\
[ -d /tmp/user-content/themes ] && cp -a /tmp/user-content/themes /usr/src/wordpress-user/wp-content/ || true && \\
[ -d /tmp/user-content/plugins ] && cp -a /tmp/user-content/plugins /usr/src/wordpress-user/wp-content/ || true && \\
[ -d /tmp/user-content/uploads ] && cp -a /tmp/user-content/uploads /usr/src/wordpress-user/wp-content/ || true; \\
fi && \\
if [ -f /tmp/user-content/wp-config.php ]; then \\
echo ">>> Saving custom wp-config.php" && \\
cp /tmp/user-content/wp-config.php /usr/src/wordpress-user/wp-config.php; \\
fi && \\
# wp-config.php is intentionally NOT copied — docker-entrypoint.sh generates it
# from WORDPRESS_DB_* env vars so credentials always match the deployed database.
if [ -f /tmp/user-content/.htaccess ]; then \\
echo ">>> Copying .htaccess" && \\
cp /tmp/user-content/.htaccess /var/www/html/.htaccess; \\
@@ -812,30 +1151,22 @@ RUN mkdir -p /usr/src/wordpress-user && \\
echo ">>> WordPress user content staged"
# Custom entrypoint:
# 1. Merge staged wp-content into the PVC mount on first run
# 2. Optionally apply user's wp-config.php (saved during build)
# 3. Hand off to the official docker-entrypoint.sh which handles
# WORDPRESS_DB_* env-var injection and first-run setup
# 1. Merge staged wp-content into the PVC mount (every start — idempotent)
# 2. Hand off to official docker-entrypoint.sh which creates wp-config.php
# from WORDPRESS_DB_* env vars (never use uploaded wp-config with old credentials)
RUN { \\
echo '#!/bin/bash'; \\
echo 'set -e'; \\
echo ''; \\
echo '# ── Merge user wp-content into PVC (first run only) ──'; \\
echo 'if [ -d /usr/src/wordpress-user/wp-content ] && [ ! -f /var/www/html/wp-content/.user-content-merged ]; then'; \\
echo ' echo ">>> First run: merging user wp-content into PVC..."'; \\
echo '# ── Merge user wp-content into PVC ──'; \\
echo 'if [ -d /usr/src/wordpress-user/wp-content ]; then'; \\
echo ' echo ">>> Merging user wp-content into PVC..."'; \\
echo ' mkdir -p /var/www/html/wp-content'; \\
echo ' cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/'; \\
echo ' touch /var/www/html/wp-content/.user-content-merged'; \\
echo ' chown -R www-data:www-data /var/www/html/wp-content'; \\
echo ' echo ">>> User wp-content merged successfully"'; \\
echo 'fi'; \\
echo ''; \\
echo '# ── Apply user wp-config.php if docker-entrypoint has not created one yet ──'; \\
echo 'if [ -f /usr/src/wordpress-user/wp-config.php ] && [ ! -f /var/www/html/wp-config.php ]; then'; \\
echo ' echo ">>> Applying user wp-config.php"'; \\
echo ' cp /usr/src/wordpress-user/wp-config.php /var/www/html/wp-config.php'; \\
echo ' chown www-data:www-data /var/www/html/wp-config.php'; \\
echo 'fi'; \\
echo ''; \\
echo 'exec docker-entrypoint.sh apache2-foreground'; \\
} > /usr/local/bin/cloudhost-entrypoint.sh && chmod +x /usr/local/bin/cloudhost-entrypoint.sh
` : `# Fresh install — no user content to merge
@@ -1135,12 +1466,21 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
jobName: string,
namespace: string,
timeoutSeconds: number,
deploymentId?: string,
): Promise<void> {
const startTime = Date.now();
const timeoutMs = timeoutSeconds * 1000;
let lastLoggedStatus = '';
while (Date.now() - startTime < timeoutMs) {
this.throwIfCancelled(deploymentId);
const elapsed = Date.now() - startTime;
const buildPercent = Math.min(90, 15 + Math.round((elapsed / timeoutMs) * 75));
this.setProgress(deploymentId, {
phase: 'building',
percent: buildPercent,
message: 'Building Docker image...',
});
// ── Check Job status (with retry for transient connection errors) ──
let job: { body: k8s.V1Job };
try {
@@ -42,12 +42,25 @@ export class DeploymentsController {
return { logs: await this.deploymentsService.getLogs(appId, req.user.id) };
}
@Get('applications/:appId/build-progress')
@ApiOperation({ summary: 'Get real-time build/upload progress for latest deployment' })
async getBuildProgress(@Param('appId') appId: string, @Request() req: any) {
return { progress: await this.deploymentsService.getBuildProgress(appId, req.user.id) };
}
@Get('applications/:appId/build-logs')
@ApiOperation({ summary: 'Get build logs for latest deployment' })
async getBuildLogs(@Param('appId') appId: string, @Request() req: any) {
return this.deploymentsService.getBuildLogs(appId, req.user.id);
}
@Post('applications/:appId/cancel')
@ApiOperation({ summary: 'Cancel an in-progress build/deploy pipeline' })
async cancel(@Param('appId') appId: string, @Request() req: any) {
const deployment = await this.deploymentsService.cancelDeployment(appId, req.user.id);
return { message: 'Deployment cancelled', deployment };
}
@Post('applications/:appId/stop')
@ApiOperation({ summary: 'Stop an application' })
async stop(@Param('appId') appId: string, @Request() req: any) {
+100 -8
View File
@@ -1,11 +1,11 @@
import { Injectable, NotFoundException, Logger, Inject, forwardRef } from '@nestjs/common';
import { Injectable, NotFoundException, BadRequestException, Logger, Inject, forwardRef } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import * as fs from 'fs';
import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService } from '../build/build.service';
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
import { DeploymentStatus } from '../common/enums';
@Injectable()
@@ -46,7 +46,7 @@ export class DeploymentsService {
try {
// Step 1: Build image
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
const { imageUri, buildLog } = await this.buildService.buildImage(app);
const { imageUri, buildLog } = await this.buildService.buildImage(app, deploymentId);
// Save build log
await this.deploymentsRepository.update(deploymentId, { buildLog });
@@ -56,15 +56,23 @@ export class DeploymentsService {
// Step 3: Deploy to Kubernetes
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
const k8sResources = await this.kubernetesService.deployApplication(app, imageUri);
this.buildService.setProgress(deploymentId, {
phase: 'deploying',
percent: 92,
message: 'Deploying to Kubernetes...',
});
// If a DB dump will be restored, deploy with 0 replicas first so WordPress
// does not initialize empty tables before the dump is imported.
const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath);
const deployApp = hasDbDump ? { ...app, replicas: 0 } : app;
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri);
// Step 3.5: Restore DB dump if one was uploaded (must happen after deploy creates the namespace + DB)
if (app.dbDumpPath && fs.existsSync(app.dbDumpPath)) {
if (hasDbDump) {
this.logger.log(`Restoring DB dump for ${app.name} from ${app.dbDumpPath}`);
try {
// Wait for the database pod to be Ready before restoring
await this.kubernetesService.waitForDatabaseReady(app, 120_000);
// Re-fetch app to ensure we have latest data
const freshApp = await this.applicationsService.findOne(app.id);
const result = await this.kubernetesService.restoreDatabaseDump(freshApp, freshApp.dbDumpPath!);
if (result.success) {
@@ -74,18 +82,43 @@ export class DeploymentsService {
}
} catch (e: any) {
this.logger.warn(`DB dump restore error for ${app.name}: ${e.message}`);
// Don't fail the deployment — DB restore is a best-effort step
}
// Scale WordPress app up after restore (or even if restore failed)
try {
await this.kubernetesService.scaleDeployment(app, app.replicas || 1);
this.logger.log(`Scaled ${app.name} to ${app.replicas || 1} replica(s) after DB restore`);
} catch (e: any) {
this.logger.warn(`Failed to scale up ${app.name} after DB restore: ${e.message}`);
}
}
// Step 4: Mark success
this.buildService.setProgress(deploymentId, {
phase: 'done',
percent: 100,
message: 'Deployment complete',
});
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.RUNNING,
k8sResources,
finishedAt: new Date(),
});
} catch (error: any) {
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
this.logger.log(`Deployment ${deploymentId} cancelled by user`);
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.FAILED,
errorMessage: 'Cancelled by user',
finishedAt: new Date(),
});
return;
}
this.logger.error(`Deployment ${deploymentId} failed:`, error);
this.buildService.setProgress(deploymentId, {
phase: 'failed',
percent: 0,
message: error.message || 'Deployment failed',
});
// Save build log if available (attached by build service on failure)
const buildLog = error.buildLog || null;
@@ -146,6 +179,65 @@ export class DeploymentsService {
};
}
async getBuildProgress(applicationId: string, userId: string): Promise<BuildProgress | null> {
await this.applicationsService.findOne(applicationId, userId);
const latest = await this.deploymentsRepository.findOne({
where: { applicationId },
order: { createdAt: 'DESC' },
});
if (!latest) return null;
const progress = this.buildService.getProgress(latest.id);
if (progress) return progress;
// No in-memory progress — infer from deployment status
if (latest.status === DeploymentStatus.RUNNING) {
return { phase: 'done', percent: 100, message: 'Deployment complete' };
}
if (latest.status === DeploymentStatus.FAILED) {
return { phase: 'failed', percent: 0, message: latest.errorMessage || 'Deployment failed' };
}
if (latest.status === DeploymentStatus.BUILDING) {
return { phase: 'building', percent: 0, message: 'Building...' };
}
if (latest.status === DeploymentStatus.DEPLOYING) {
return { phase: 'deploying', percent: 90, message: 'Deploying...' };
}
return null;
}
async cancelDeployment(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId);
const latest = await this.deploymentsRepository.findOne({
where: { applicationId },
order: { createdAt: 'DESC' },
});
if (!latest) {
throw new NotFoundException('No deployment found');
}
const inProgress = [
DeploymentStatus.PENDING,
DeploymentStatus.BUILDING,
DeploymentStatus.DEPLOYING,
];
if (!inProgress.includes(latest.status as DeploymentStatus)) {
throw new BadRequestException('No deployment in progress to cancel');
}
await this.buildService.cancelBuild(latest.id);
await this.buildService.cleanupBuildResourcesForApp(app);
latest.status = DeploymentStatus.FAILED;
latest.errorMessage = 'Cancelled by user';
latest.finishedAt = new Date();
return this.deploymentsRepository.save(latest);
}
async stopDeployment(applicationId: string, userId: string): Promise<Deployment | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
await this.kubernetesService.scaleDeployment(app, 0);
@@ -50,7 +50,6 @@ describe('buildHelmValues logic', () => {
},
wordpress: {
enabled: isWordPress,
wpContentStorageSize: '2Gi',
},
changeCause: `Deploy ${imageUri} at 2024-01-01T00:00:00.000Z`,
};
+3 -3
View File
@@ -105,6 +105,7 @@ export class KubernetesService implements OnModuleInit {
image: imageUri,
port: app.port,
replicas: app.replicas,
storageSize: app.appStorageSize || '2Gi',
},
resources: {
cpuRequest: app.cpuRequest,
@@ -141,7 +142,6 @@ export class KubernetesService implements OnModuleInit {
},
wordpress: {
enabled: isWordPress,
wpContentStorageSize: '2Gi',
},
redis: {
enabled: app.enableRedis || false,
@@ -2431,7 +2431,7 @@ export class KubernetesService implements OnModuleInit {
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 pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-archive-${Date.now()}`;
const job: k8s.V1Job = {
@@ -2563,7 +2563,7 @@ export class KubernetesService implements OnModuleInit {
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 pvcName = `${app.name}-storage`;
const jobName = `${app.name}-wp-restore-${Date.now()}`;
const secretName = `${jobName}-archive`;
@@ -8,6 +8,7 @@ import type { Application, Deployment, ResourceUsage, ClusterPublic, ClusterPool
import { useState, useRef, useCallback, useEffect } from 'react';
import { Hexagon, Rocket, Play, Square, RotateCw, Globe, Package, Server, Scale, CheckCircle, Clock, XCircle, AlertCircle, Link, GitBranch, KeyRound, FolderUp, BarChart3, ChevronDown, FileText, Monitor, Hammer, Settings, RefreshCw, Upload, Circle, Pin, Database, Eye, EyeOff, Copy, Check, History, Download, RotateCcw, Camera, Trash2, Archive, Zap, Wallet, CreditCard, AlertTriangle } from 'lucide-react';
import { useConfirm } from '@/components/confirm-modal';
import { BuildProgressModal } from '@/components/build-progress-modal';
const statusColors: Record<string, string> = {
running: 'badge-green',
@@ -2375,6 +2376,7 @@ export default function AppDetailPage() {
</div>
)}
</div>
<BuildProgressModal appId={appId} enabled={isInProgress} />
</div>
);
}
@@ -0,0 +1,125 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '@/lib/api';
import { Loader2, Upload, Hammer, Rocket, CheckCircle, XCircle, X } from 'lucide-react';
import { toast } from 'react-toastify';
export interface BuildProgress {
phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed';
percent: number;
bytesUploaded?: number;
totalBytes?: number;
message?: string;
}
function formatBytes(bytes?: number): string {
if (!bytes) return '';
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
const phaseConfig = {
uploading: { label: 'Uploading source to cluster', icon: Upload, bg: 'bg-blue-500' },
building: { label: 'Building Docker image', icon: Hammer, bg: 'bg-amber-500' },
deploying: { label: 'Deploying to Kubernetes', icon: Rocket, bg: 'bg-purple-500' },
done: { label: 'Deployment complete', icon: CheckCircle, bg: 'bg-green-500' },
failed: { label: 'Deployment failed', icon: XCircle, bg: 'bg-red-500' },
};
export function BuildProgressModal({ appId, enabled }: { appId: string; enabled: boolean }) {
const queryClient = useQueryClient();
const [dismissed, setDismissed] = useState(false);
useEffect(() => {
if (enabled) setDismissed(false);
}, [enabled]);
const { data } = useQuery<{ progress: BuildProgress | null }>({
queryKey: ['build-progress', appId],
queryFn: () => api.get(`/deployments/applications/${appId}/build-progress`).then((r) => r.data),
enabled: enabled && !dismissed,
refetchInterval: enabled && !dismissed ? 1500 : false,
});
const cancelMutation = useMutation({
mutationFn: () => api.post(`/deployments/applications/${appId}/cancel`),
onSuccess: () => {
toast.success('Deployment cancelled');
setDismissed(true);
queryClient.invalidateQueries({ queryKey: ['application', appId] });
queryClient.invalidateQueries({ queryKey: ['deployments', appId] });
queryClient.invalidateQueries({ queryKey: ['build-progress', appId] });
},
onError: () => toast.error('Failed to cancel deployment'),
});
const progress = data?.progress;
if (!enabled || dismissed || !progress || progress.phase === 'done') return null;
const cfg = phaseConfig[progress.phase];
const PhaseIcon = cfg.icon;
const isActive = progress.phase !== 'failed';
const showBytes = progress.phase === 'uploading' && progress.totalBytes;
const isCancelling = cancelMutation.isPending;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="relative bg-white rounded-2xl shadow-2xl p-8 max-w-md w-full mx-4 space-y-5">
<button
type="button"
onClick={() => cancelMutation.mutate()}
disabled={isCancelling || progress.phase === 'failed'}
className="absolute top-4 right-4 p-1.5 rounded-lg text-gray-400 hover:text-gray-700 hover:bg-gray-100 transition-colors disabled:opacity-50"
aria-label="Cancel and close"
title="Cancel deployment"
>
{isCancelling ? <Loader2 className="w-5 h-5 animate-spin" /> : <X className="w-5 h-5" />}
</button>
<div className="text-center">
{isActive ? (
<Loader2 className="w-12 h-12 text-primary-600 mx-auto mb-3 animate-spin" />
) : (
<PhaseIcon className="w-12 h-12 text-red-500 mx-auto mb-3" />
)}
<h3 className="text-lg font-semibold text-gray-900">{cfg.label}</h3>
{progress.message && (
<p className="text-sm text-gray-500 mt-1">{progress.message}</p>
)}
</div>
{isActive && (
<div className="space-y-2">
<div className="flex justify-between text-sm text-gray-600">
<span className="flex items-center gap-1.5">
<PhaseIcon className="w-4 h-4" />
{progress.percent}%
</span>
{showBytes && (
<span>{formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)}</span>
)}
</div>
<div className="h-3 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full ${cfg.bg} rounded-full transition-all duration-500 ease-out`}
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
)}
{progress.phase === 'failed' && progress.message && (
<p className="text-sm text-red-600 text-center">{progress.message}</p>
)}
{isActive && (
<p className="text-xs text-gray-400 text-center">
Click the close button to cancel and remove cluster build resources.
</p>
)}
</div>
</div>
);
}