From 0c0a6cd5be043d08a3aa7ddccbb6874443efd78d Mon Sep 17 00:00:00 2001 From: keyhan Date: Thu, 14 May 2026 16:03:45 +0330 Subject: [PATCH] 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 --- .../cloudhost-app/templates/deployment.yaml | 5 - .../helm/cloudhost-app/templates/wp-pvc.yaml | 19 - backend/helm/cloudhost-app/values.yaml | 1 - .../src/applications/applications.service.ts | 7 +- backend/src/build/build.service.spec.ts | 23 +- backend/src/build/build.service.ts | 420 ++++++++++++++++-- .../src/deployments/deployments.controller.ts | 13 + .../src/deployments/deployments.service.ts | 108 ++++- .../src/kubernetes/kubernetes.service.spec.ts | 1 - backend/src/kubernetes/kubernetes.service.ts | 6 +- frontend/src/app/dashboard/apps/[id]/page.tsx | 2 + .../src/components/build-progress-modal.tsx | 125 ++++++ 12 files changed, 637 insertions(+), 93 deletions(-) delete mode 100644 backend/helm/cloudhost-app/templates/wp-pvc.yaml create mode 100644 frontend/src/components/build-progress-modal.tsx diff --git a/backend/helm/cloudhost-app/templates/deployment.yaml b/backend/helm/cloudhost-app/templates/deployment.yaml index ba91b84..0b1da0e 100644 --- a/backend/helm/cloudhost-app/templates/deployment.yaml +++ b/backend/helm/cloudhost-app/templates/deployment.yaml @@ -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: {} diff --git a/backend/helm/cloudhost-app/templates/wp-pvc.yaml b/backend/helm/cloudhost-app/templates/wp-pvc.yaml deleted file mode 100644 index 0e4860b..0000000 --- a/backend/helm/cloudhost-app/templates/wp-pvc.yaml +++ /dev/null @@ -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 }} diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index b0a3635..f264a28 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -51,7 +51,6 @@ database: # ── WordPress-specific ─────────────────────────────────── wordpress: enabled: false - wpContentStorageSize: "2Gi" # ── Redis ────────────────────────────────────────────── redis: diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index e1e51b4..48b873d 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -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, diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts index 74d70c6..8cfe769 100644 --- a/backend/src/build/build.service.spec.ts +++ b/backend/src/build/build.service.spec.ts @@ -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', () => { diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index 78527fd..7e9d511 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -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(); + private readonly activeBuilds = new Map(); 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): 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 { + 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[] = []; + 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 { + const buildNamespace = this.configService.get('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[] = []; + + 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('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 { /* 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 { + const maxAttempts = 3; + + const runOnce = () => new Promise((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 { 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 { 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 { diff --git a/backend/src/deployments/deployments.controller.ts b/backend/src/deployments/deployments.controller.ts index e3d1b21..68e514c 100644 --- a/backend/src/deployments/deployments.controller.ts +++ b/backend/src/deployments/deployments.controller.ts @@ -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) { diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index c0a5b11..1ff48bf 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -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 { + 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 { + 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 { const app = await this.applicationsService.findOne(applicationId, userId); await this.kubernetesService.scaleDeployment(app, 0); diff --git a/backend/src/kubernetes/kubernetes.service.spec.ts b/backend/src/kubernetes/kubernetes.service.spec.ts index 7270aef..086ac22 100644 --- a/backend/src/kubernetes/kubernetes.service.spec.ts +++ b/backend/src/kubernetes/kubernetes.service.spec.ts @@ -50,7 +50,6 @@ describe('buildHelmValues logic', () => { }, wordpress: { enabled: isWordPress, - wpContentStorageSize: '2Gi', }, changeCause: `Deploy ${imageUri} at 2024-01-01T00:00:00.000Z`, }; diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index cf0fea2..00b4c7e 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -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`; diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index 9452bd6..42f7ba0 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -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 = { running: 'badge-green', @@ -2375,6 +2376,7 @@ export default function AppDetailPage() { )} + ); } diff --git a/frontend/src/components/build-progress-modal.tsx b/frontend/src/components/build-progress-modal.tsx new file mode 100644 index 0000000..908858e --- /dev/null +++ b/frontend/src/components/build-progress-modal.tsx @@ -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 ( +
+
+ + +
+ {isActive ? ( + + ) : ( + + )} +

{cfg.label}

+ {progress.message && ( +

{progress.message}

+ )} +
+ + {isActive && ( +
+
+ + + {progress.percent}% + + {showBytes && ( + {formatBytes(progress.bytesUploaded)} / {formatBytes(progress.totalBytes)} + )} +
+
+
+
+
+ )} + + {progress.phase === 'failed' && progress.message && ( +

{progress.message}

+ )} + + {isActive && ( +

+ Click the close button to cancel and remove cluster build resources. +

+ )} +
+
+ ); +}