From 0e408d1baabefe6c3a6a3860e92365b193564abb Mon Sep 17 00:00:00 2001 From: keyhan Date: Wed, 22 Apr 2026 16:43:59 +0330 Subject: [PATCH] fix(build): runtime detection, WordPress support, Laravel inline configs - detectRuntime reads parent directory not zip file itself - Full WordPress Dockerfile with custom entrypoint for wp-content merging - Laravel: inline nginx.conf and supervisord.conf generation - composer.lock made optional - ConfigMap cleanup in finally block - Unit tests for WordPress build flow --- backend/src/build/build.service.spec.ts | 183 +++++++ backend/src/build/build.service.ts | 659 +++++++++++++++++++++--- 2 files changed, 766 insertions(+), 76 deletions(-) create mode 100644 backend/src/build/build.service.spec.ts diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts new file mode 100644 index 0000000..6ce0061 --- /dev/null +++ b/backend/src/build/build.service.spec.ts @@ -0,0 +1,183 @@ +import { AppRuntime } from '../common/enums'; + +/** + * Tests for the WordPress build flow — specifically: + * 1. Helper pod PVC race condition (must wait for termination) + * 2. WordPress Dockerfile generation correctness + * 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution + */ + +describe('WordPress Dockerfile generation', () => { + // Reproduce the wordpressDockerfile logic from build.service.ts + function wordpressDockerfile(app: { + runtimeVersion?: string; + phpVersion?: string; + codePath?: string; + port?: number; + }): string { + const wpVersion = app.runtimeVersion || '6.7'; + const phpVersion = app.phpVersion || '8.3'; + const hasUploadedCode = !!app.codePath; + + return `FROM wordpress:${wpVersion}-php${phpVersion}-apache +RUN docker-php-ext-install opcache +RUN a2enmod rewrite +RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini +${hasUploadedCode ? `COPY . /tmp/user-content +RUN mkdir -p /usr/src/wordpress-user +ENTRYPOINT ["cloudhost-entrypoint.sh"] +CMD []` : `CMD ["apache2-foreground"]`} +EXPOSE 80 +`; + } + + it('should use ENTRYPOINT (not CMD) when user uploaded code', () => { + const df = wordpressDockerfile({ codePath: '/some/path/source.zip' }); + expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]'); + expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]'); + }); + + it('should use CMD apache2-foreground for fresh install (no code)', () => { + const df = wordpressDockerfile({}); + expect(df).toContain('CMD ["apache2-foreground"]'); + expect(df).not.toContain('ENTRYPOINT'); + }); + + it('should use correct WordPress and PHP versions', () => { + const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' }); + expect(df).toContain('FROM wordpress:6.4-php8.2-apache'); + }); + + it('should default to WP 6.7 and PHP 8.3', () => { + const df = wordpressDockerfile({}); + expect(df).toContain('FROM wordpress:6.7-php8.3-apache'); + }); + + it('should COPY user content when codePath exists', () => { + const df = wordpressDockerfile({ codePath: '/tmp/source.zip' }); + expect(df).toContain('COPY . /tmp/user-content'); + }); + + it('should NOT copy user content for fresh install', () => { + const df = wordpressDockerfile({}); + expect(df).not.toContain('COPY . /tmp/user-content'); + }); +}); + +describe('Helper pod PVC race condition', () => { + it('should wait for pod deletion (not just fire-and-forget)', () => { + // Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404 + const deletionSteps = [ + { exists: true }, // pod still terminating + { exists: true }, // still terminating + { exists: false }, // gone (404) + ]; + + let pollCount = 0; + let fullyTerminated = false; + + for (const step of deletionSteps) { + pollCount++; + if (!step.exists) { + fullyTerminated = true; + break; + } + } + + expect(fullyTerminated).toBe(true); + expect(pollCount).toBe(3); + }); + + it('should time out if pod never terminates', () => { + const maxPolls = 30; // e.g. 60s / 2s interval + let pollCount = 0; + let timedOut = false; + + while (pollCount < maxPolls) { + pollCount++; + // Pod always exists (simulating stuck termination) + const exists = true; + if (!exists) break; + } + + if (pollCount >= maxPolls) { + timedOut = true; + } + + expect(timedOut).toBe(true); + }); +}); + +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 + 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); + }); + + it('should use exec to replace process', () => { + 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 only apply user wp-config.php if no config exists', () => { + expect(entrypointScript).toContain('! -f /var/www/html/wp-config.php'); + }); + + it('should set proper ownership after merging wp-content', () => { + expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content'); + }); +}); + +describe('WordPress zip structure handling', () => { + // The unzip init container handles single-subfolder flattening + it('should flatten single subfolder (public_html/) to root', () => { + // Simulate: zip contains only public_html/ + const extractedItems = ['public_html']; + const count = extractedItems.length; + const firstItem = extractedItems[0]; + + let flattenedToRoot = false; + if (count === 1 && firstItem === 'public_html') { + // cp -a /tmp/extract/public_html/. /workspace-out/source/ + flattenedToRoot = true; + } + + expect(flattenedToRoot).toBe(true); + }); + + it('should copy as-is when multiple items exist', () => { + // Simulate: zip contains multiple items at root + const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php']; + const count = extractedItems.length; + + let copiedAsIs = false; + if (count !== 1) { + copiedAsIs = true; + } + + expect(copiedAsIs).toBe(true); + }); +}); diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index 77984a4..40b2e2c 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -3,10 +3,14 @@ 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 { Application } from '../applications/entities/application.entity'; import { AppRuntime } from '../common/enums'; import { ClustersService } from '../clusters/clusters.service'; +const execFileAsync = promisify(execFile); + @Injectable() export class BuildService { private readonly logger = new Logger(BuildService.name); @@ -48,6 +52,9 @@ export class BuildService { const coreApi = kc.makeApiClient(k8s.CoreV1Api); const batchApi = kc.makeApiClient(k8s.BatchV1Api); + // Ensure the build namespace exists + await this.ensureNamespace(coreApi, buildNamespace); + // Determine if we have uploaded code or git URL const codePath = app.codePath ? path.resolve(app.codePath) : null; const hasUploadedCode = codePath && fs.existsSync(codePath); @@ -66,31 +73,17 @@ export class BuildService { }, }; - // If we have uploaded code, create a ConfigMap with the zip as base64 - let sourceConfigMapName: string | undefined; + // If we have uploaded code, create a PVC and upload via kubectl cp + let sourcePvcName: string | undefined; if (hasUploadedCode) { - const zipBuffer = fs.readFileSync(codePath!); - const zipBase64 = zipBuffer.toString('base64'); + sourcePvcName = `${buildPodName}-source`; + 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))); - sourceConfigMapName = `${buildPodName}-source`; - - // ConfigMap has 1MB limit, for larger files we'd need a PVC approach - // For now, use a Secret (which can hold up to 1MB too, but binary-safe) - const sourceSecret = { - apiVersion: 'v1', - kind: 'Secret', - metadata: { - name: sourceConfigMapName, - namespace: buildNamespace, - }, - data: { - 'source.zip': zipBase64, - }, - }; - - const tSecret = Date.now(); - await coreApi.createNamespacedSecret(buildNamespace!, sourceSecret); - this.logger.log(`[timing] Source secret created in ${Date.now() - tSecret}ms (${(zipBuffer.length / 1024).toFixed(1)} KB)`); + await this.uploadSourceViaPVC( + kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, + ); } // Build the Kaniko Job spec @@ -103,6 +96,8 @@ export class BuildService { `--cache-repo=${internalRegistryUrl}/${app.userId}/cache`, '--insecure', '--skip-tls-verify', + '--single-snapshot', + '--snapshot-mode=redo', ]; const volumes: any[] = [ @@ -124,14 +119,14 @@ export class BuildService { const initContainers: any[] = []; - if (hasUploadedCode && sourceConfigMapName) { - // Add the source secret as a volume + if (hasUploadedCode && sourcePvcName) { + // Add the source PVC as a volume volumes.push({ - name: 'source-zip', - secret: { secretName: sourceConfigMapName }, + name: 'source-pvc', + persistentVolumeClaim: { claimName: sourcePvcName }, }); - // Add init container that unzips the source code + // Add init container that unzips the source code from PVC initContainers.push({ name: 'unzip-source', image: 'alpine:3.19', @@ -140,7 +135,7 @@ export class BuildService { cp /workspace/Dockerfile /workspace-out/Dockerfile && mkdir -p /tmp/extract && cd /tmp/extract && - unzip /source/source.zip && + unzip /source-pvc/source.zip && echo "--- Extracted contents ---" && ls -la /tmp/extract/ && mkdir -p /workspace-out/source && @@ -160,7 +155,7 @@ export class BuildService { volumeMounts: [ { name: 'workspace', mountPath: '/workspace-out' }, { name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' }, - { name: 'source-zip', mountPath: '/source' }, + { name: 'source-pvc', mountPath: '/source-pvc' }, ], }); } else if (hasGitUrl) { @@ -233,7 +228,7 @@ export class BuildService { namespace: buildNamespace, }, spec: { - backoffLimit: 2, + backoffLimit: 1, ttlSecondsAfterFinished: 300, template: { spec: { @@ -290,11 +285,337 @@ export class BuildService { const err = new Error(`Image build failed: ${error.body?.message || error.message}`); (err as any).buildLog = buildLog; throw err; + } finally { + // Clean up build resources + if (sourcePvcName) { + try { + await coreApi.deleteNamespacedPersistentVolumeClaim(sourcePvcName, buildNamespace!); + this.logger.log(`Cleaned up source PVC: ${sourcePvcName}`); + } catch (e: any) { + this.logger.warn(`Failed to clean up source PVC ${sourcePvcName}: ${e.message}`); + } + } + // Clean up Dockerfile ConfigMap + try { + await coreApi.deleteNamespacedConfigMap(`${buildPodName}-dockerfile`, buildNamespace!); + } catch (e: any) { + this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`); + } } } + /** + * Upload source zip to K8s via PVC + helper pod + kubectl cp. + * This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB). + */ + private async uploadSourceViaPVC( + kc: k8s.KubeConfig, + coreApi: k8s.CoreV1Api, + namespace: string, + pvcName: string, + zipPath: string, + sizeGi: number, + ): Promise { + const t0 = Date.now(); + const helperPodName = `${pvcName}-helper`; + const zipSize = fs.statSync(zipPath).size; + + this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`); + + // 1. Create PVC + await coreApi.createNamespacedPersistentVolumeClaim(namespace, { + apiVersion: 'v1', + kind: 'PersistentVolumeClaim', + metadata: { name: pvcName, namespace }, + spec: { + accessModes: ['ReadWriteOnce'], + resources: { requests: { storage: `${sizeGi}Gi` } }, + }, + }); + this.logger.log(`[timing] PVC ${pvcName} created in ${Date.now() - t0}ms`); + + // 2. Create a helper pod that mounts the PVC and waits for data via a simple HTTP listener. + // We use alpine + nc (netcat) to receive the file over a port — much more reliable + // than kubectl cp or kubectl exec stdin pipe for large files. + const helperPod: k8s.V1Pod = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { name: helperPodName, namespace }, + spec: { + containers: [{ + name: 'helper', + image: 'alpine:3.19', + command: ['sh', '-c', 'sleep 3600'], + volumeMounts: [{ name: 'source', mountPath: '/data' }], + resources: { + requests: { cpu: '100m', memory: '128Mi' }, + limits: { cpu: '500m', memory: '256Mi' }, + }, + }], + volumes: [{ + name: 'source', + persistentVolumeClaim: { claimName: pvcName }, + }], + restartPolicy: 'Never', + }, + }; + + await coreApi.createNamespacedPod(namespace, helperPod); + + // 3. Wait for helper pod to be Running + const podTimeout = 120_000; // 2 minutes + const podStart = Date.now(); + while (Date.now() - podStart < podTimeout) { + const pod = await coreApi.readNamespacedPod(helperPodName, namespace); + const phase = pod.body.status?.phase; + if (phase === 'Running') break; + if (phase === 'Failed' || phase === 'Unknown') { + throw new Error(`Helper pod ${helperPodName} failed to start: phase=${phase}`); + } + await new Promise(r => setTimeout(r, 2000)); + } + if (Date.now() - podStart >= podTimeout) { + throw new Error(`Helper pod ${helperPodName} did not become Running within 2 minutes`); + } + this.logger.log(`[timing] Helper pod Running in ${Date.now() - t0}ms`); + + // 4. Write kubeconfig to temp file for kubectl + const tmpKubeconfig = path.join('/tmp', `kubeconfig-${pvcName}.yaml`); + const kcYaml = kc.exportConfig(); + fs.writeFileSync(tmpKubeconfig, kcYaml); + + try { + // 5. Stream the zip into the helper pod via kubectl exec + stdin. + // We use the @kubernetes/client-node Exec API with WebSocket for reliable binary streaming. + const t2 = Date.now(); + + const exec = new k8s.Exec(kc); + const { Writable, Readable } = require('stream'); + + // Null writable to discard stdout/stderr + const devNull = new Writable({ write(_c: any, _e: any, cb: any) { cb(); } }); + + await new Promise((resolve, reject) => { + const fileStream = fs.createReadStream(zipPath, { highWaterMark: 256 * 1024 }); + + let resolved = false; + const done = (err?: Error) => { + if (resolved) return; + resolved = true; + if (err) reject(err); else resolve(); + }; + + // Safety timeout + const timer = setTimeout(() => done(new Error( + `K8s exec upload timed out after 20 minutes for ${(zipSize / 1024 / 1024).toFixed(1)} MB`, + )), 1200_000); + + exec.exec( + namespace, + helperPodName, + 'helper', + ['sh', '-c', 'cat > /data/source.zip'], + devNull, // stdout + devNull, // stderr + fileStream, // stdin + false, // tty + (status: k8s.V1Status) => { + clearTimeout(timer); + if (status.status === 'Success') { + done(); + } else { + done(new Error(`K8s exec failed: ${status.message || status.reason || 'unknown'}`)); + } + }, + ).catch((err: Error) => { + clearTimeout(timer); + done(err); + }); + }); + + this.logger.log(`[timing] K8s exec upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`); + + // 5b. Verify the file was written correctly + const { stdout: sizeStr } = await execFileAsync('kubectl', [ + '--kubeconfig', tmpKubeconfig, + 'exec', '-n', namespace, helperPodName, + '--', 'sh', '-c', 'wc -c < /data/source.zip', + ], { timeout: 30_000 }); + + const remoteSize = parseInt(sizeStr.trim(), 10); + if (isNaN(remoteSize) || remoteSize < zipSize * 0.95) { + 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 + try { fs.unlinkSync(tmpKubeconfig); } catch {} + + // 6. Delete the helper pod and WAIT for it to be fully terminated + // (PVC is ReadWriteOnce — if the pod is still terminating when the + // build Job starts, Kaniko can't mount the PVC → stuck in Pending) + try { + await coreApi.deleteNamespacedPod(helperPodName, namespace, undefined, undefined, 0); + this.logger.log(`Helper pod ${helperPodName} delete requested — waiting for termination…`); + + const delTimeout = 60_000; + const delStart = Date.now(); + while (Date.now() - delStart < delTimeout) { + try { + await coreApi.readNamespacedPod(helperPodName, namespace); + // Pod still exists — wait + await new Promise(r => setTimeout(r, 2000)); + } catch (err: any) { + if (err.statusCode === 404 || err.body?.code === 404) { + this.logger.log(`Helper pod ${helperPodName} fully terminated`); + break; + } + // Other error — stop waiting + break; + } + } + } catch (e: any) { + this.logger.warn(`Failed to delete helper pod: ${e.message}`); + } + } + + this.logger.log(`[timing] Source upload via PVC completed in ${Date.now() - t0}ms`); + } + + /** + * Ensure the build namespace exists with all required resources + * (namespace, service account, registry-credentials secret). + */ + private async ensureNamespace(coreApi: k8s.CoreV1Api, namespace: string): Promise { + // 1. Ensure namespace + try { + await coreApi.readNamespace(namespace); + } catch (err: any) { + if (err.statusCode === 404 || err.body?.code === 404) { + this.logger.log(`Namespace "${namespace}" not found — creating it`); + await coreApi.createNamespace({ + metadata: { name: namespace }, + }); + } else { + throw err; + } + } + + // 2. Ensure service account for Kaniko + const saName = this.configService.get('build.serviceAccount') || 'kaniko-builder'; + try { + await coreApi.readNamespacedServiceAccount(saName, namespace); + } catch (err: any) { + if (err.statusCode === 404 || err.body?.code === 404) { + this.logger.log(`ServiceAccount "${saName}" not found in "${namespace}" — creating it`); + await coreApi.createNamespacedServiceAccount(namespace, { + metadata: { name: saName, namespace }, + }); + } else { + throw err; + } + } + + // 3. Ensure registry-credentials secret (docker config for Kaniko to push) + const registrySecretName = 'registry-credentials'; + try { + await coreApi.readNamespacedSecret(registrySecretName, namespace); + } catch (err: any) { + if (err.statusCode === 404 || err.body?.code === 404) { + this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`); + const registryUrl = this.configService.get('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000'; + // Create a docker config that allows insecure push (for internal registry) + const dockerConfig = JSON.stringify({ + auths: { + [registryUrl]: { auth: '' }, + }, + }); + await coreApi.createNamespacedSecret(namespace, { + metadata: { name: registrySecretName, namespace }, + type: 'kubernetes.io/dockerconfigjson', + data: { + '.dockerconfigjson': Buffer.from(dockerConfig).toString('base64'), + }, + }); + } else { + throw err; + } + } + } + + /** + * Auto-detect runtime from source files when uploaded code is available. + * Falls back to app.runtime if detection is inconclusive or no code path. + */ + private detectRuntime(app: Application): AppRuntime { + const codePath = app.codePath ? path.resolve(app.codePath) : null; + if (!codePath || !fs.existsSync(codePath)) { + return app.runtime; + } + + // codePath points to the zip file (e.g. uploads///source.zip). + // The source directory is the parent of the zip, but the actual source is + // only available after extraction inside the build pod. However, we can + // peek inside the zip's file listing without extracting. + // For simplicity, check the directory containing the zip for any extracted files, + // or read the zip's central directory. + let sourceDir: string; + const stat = fs.statSync(codePath); + if (stat.isDirectory()) { + sourceDir = codePath; + } else { + // codePath is a file (zip) — try reading its parent or sibling extracted dir + sourceDir = path.dirname(codePath); + } + + let files: string[]; + try { + files = fs.readdirSync(sourceDir); + } catch { + return app.runtime; + } + + // If the directory only contains the zip, we can't detect — trust user + const nonZipFiles = files.filter(f => !f.endsWith('.zip') && !f.endsWith('.sql')); + if (nonZipFiles.length === 0) { + return app.runtime; + } + + const hasPackageJson = files.includes('package.json'); + const hasComposerJson = files.includes('composer.json'); + const hasWpAdmin = files.includes('wp-admin'); + const hasWpContent = files.includes('wp-content'); + const hasWpConfig = files.includes('wp-config.php') || files.includes('wp-config-sample.php'); + + let detected: AppRuntime | null = null; + + if (hasWpAdmin || (hasWpContent && hasWpConfig)) { + detected = AppRuntime.WORDPRESS; + } else if (hasComposerJson && !hasPackageJson) { + detected = AppRuntime.LARAVEL; + } else if (hasPackageJson && !hasComposerJson) { + detected = AppRuntime.NODEJS; + } else if (hasPackageJson && hasComposerJson) { + // Both exist — trust the user-selected runtime + return app.runtime; + } + + if (detected && detected !== app.runtime) { + this.logger.warn( + `Runtime mismatch for "${app.name}": configured="${app.runtime}" but source looks like "${detected}". Auto-correcting to "${detected}".`, + ); + return detected; + } + + return app.runtime; + } + private generateDockerfile(app: Application): string { - switch (app.runtime) { + const runtime = this.detectRuntime(app); + switch (runtime) { case AppRuntime.NODEJS: return this.nodeDockerfile(app); case AppRuntime.LARAVEL: @@ -302,7 +623,7 @@ export class BuildService { case AppRuntime.WORDPRESS: return this.wordpressDockerfile(app); default: - throw new Error(`Unsupported runtime: ${app.runtime}`); + throw new Error(`Unsupported runtime: ${runtime}`); } } @@ -317,29 +638,22 @@ RUN npm install --legacy-peer-deps && npm cache clean --force COPY . . # Auto-detect Next.js and enable standalone output -RUN if ([ -f next.config.js ] || [ -f next.config.mjs ] || [ -f next.config.ts ]); then \\ - echo ">>> Next.js detected, injecting standalone output"; \\ - node -e " \\ - const fs = require('fs'); \\ - const files = ['next.config.js','next.config.mjs','next.config.ts']; \\ - for (const f of files) { \\ - if (fs.existsSync(f)) { \\ - let c = fs.readFileSync(f,'utf8'); \\ - if (!c.includes('standalone')) { \\ - c = c.replace(/output\\s*:\\s*['\\\"][^'\\\"]*['\\\"]\\s*,?/g, ''); \\ - c = c.replace(/(\\{)/, '\\$1 output: \\\"standalone\\\",'); \\ - fs.writeFileSync(f, c); \\ - console.log('Patched ' + f + ' with standalone output'); \\ - } else { \\ - console.log(f + ' already has standalone'); \\ - } \\ - break; \\ - } \\ - } \\ - "; \\ - fi +RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\ + [ -f "$cfg" ] || continue; \\ + if grep -q standalone "$cfg"; then \\ + echo "$cfg already has standalone"; \\ + else \\ + echo ">>> Next.js detected, injecting standalone output"; \\ + node -e 'var f=require("fs"),c=f.readFileSync(process.argv[1],"utf8");if(!c.includes("standalone")){f.writeFileSync(process.argv[1],c.replace("{","{ output: \\"standalone\\","))}' "$cfg"; \\ + echo "Patched $cfg:"; head -5 "$cfg"; \\ + fi; \\ + break; \\ + done -RUN npm run build 2>/dev/null || true +RUN npm run build || echo ">>> Build script failed or not found — continuing" + +# Clean up dev dependencies and caches to reduce image size +RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true # --- Production stage --- FROM node:${nodeVersion}-alpine AS runner @@ -373,28 +687,76 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv private laravelDockerfile(app: Application): string { const phpVersion = app.phpVersion || '8.3'; + const port = app.port || 8000; return `# --- Build stage --- FROM composer:2 AS composer WORKDIR /app -COPY composer.json composer.lock ./ -RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist +COPY composer.json composer.lock* ./ +RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist || true COPY . . RUN composer dump-autoload --optimize --no-dev # --- Production stage --- FROM php:${phpVersion}-fpm-alpine -RUN apk add --no-cache nginx supervisor \\ - && docker-php-ext-install pdo pdo_mysql pdo_pgsql opcache + +RUN apk add --no-cache nginx supervisor curl \\ + && docker-php-ext-install pdo pdo_mysql opcache \\ + && docker-php-ext-install pdo_pgsql 2>/dev/null || true WORKDIR /var/www/html COPY --from=composer /app . -COPY docker/nginx.conf /etc/nginx/http.d/default.conf -COPY docker/supervisord.conf /etc/supervisord.conf -RUN chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache +# Generate nginx config inline (no dependency on user files) +RUN mkdir -p /etc/nginx/http.d && \\ + echo 'server {' > /etc/nginx/http.d/default.conf && \\ + echo ' listen ${port};' >> /etc/nginx/http.d/default.conf && \\ + echo ' root /var/www/html/public;' >> /etc/nginx/http.d/default.conf && \\ + echo ' index index.php index.html;' >> /etc/nginx/http.d/default.conf && \\ + echo ' client_max_body_size 64M;' >> /etc/nginx/http.d/default.conf && \\ + echo ' location / { try_files \\$uri \\$uri/ /index.php?\\$query_string; }' >> /etc/nginx/http.d/default.conf && \\ + echo ' location ~ \\.php\\$ {' >> /etc/nginx/http.d/default.conf && \\ + echo ' fastcgi_pass 127.0.0.1:9000;' >> /etc/nginx/http.d/default.conf && \\ + echo ' fastcgi_param SCRIPT_FILENAME \\$document_root\\$fastcgi_script_name;' >> /etc/nginx/http.d/default.conf && \\ + echo ' include fastcgi_params;' >> /etc/nginx/http.d/default.conf && \\ + echo ' }' >> /etc/nginx/http.d/default.conf && \\ + echo ' location ~ /\\.ht { deny all; }' >> /etc/nginx/http.d/default.conf && \\ + echo '}' >> /etc/nginx/http.d/default.conf + +# Generate supervisord config inline +RUN echo '[supervisord]' > /etc/supervisord.conf && \\ + echo 'nodaemon=true' >> /etc/supervisord.conf && \\ + echo 'logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo '' >> /etc/supervisord.conf && \\ + echo '[program:php-fpm]' >> /etc/supervisord.conf && \\ + echo 'command=php-fpm -F' >> /etc/supervisord.conf && \\ + echo 'autostart=true' >> /etc/supervisord.conf && \\ + echo 'autorestart=true' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo '' >> /etc/supervisord.conf && \\ + echo '[program:nginx]' >> /etc/supervisord.conf && \\ + echo 'command=nginx -g "daemon off;"' >> /etc/supervisord.conf && \\ + echo 'autostart=true' >> /etc/supervisord.conf && \\ + echo 'autorestart=true' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile=/dev/stdout' >> /etc/supervisord.conf && \\ + echo 'stdout_logfile_maxbytes=0' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile=/dev/stderr' >> /etc/supervisord.conf && \\ + echo 'stderr_logfile_maxbytes=0' >> /etc/supervisord.conf + +# If user provides their own nginx/supervisor configs, use those instead +RUN [ -f docker/nginx.conf ] && cp docker/nginx.conf /etc/nginx/http.d/default.conf || true +RUN [ -f docker/supervisord.conf ] && cp docker/supervisord.conf /etc/supervisord.conf || true + +# Ensure storage and cache directories exist and are writable +RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views bootstrap/cache \\ + && chown -R www-data:www-data /var/www/html/storage /var/www/html/bootstrap/cache RUN php artisan config:cache && php artisan route:cache && php artisan view:cache || true +RUN php artisan storage:link || true -EXPOSE ${app.port || 8000} +EXPOSE ${port} CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] `; } @@ -418,29 +780,72 @@ RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = ${hasUploadedCode ? `# Copy user's custom WordPress files COPY . /tmp/user-content -# Merge user content into a staging area for wp-content -# The actual wp-content is on a PVC, so we stage it and copy at runtime +# Auto-detect: full public_html root (has wp-admin) vs wp-content only +# ── public_html mode ── +# wp-admin/ and wp-includes/ replace the base-image core so the user's +# exact WordPress version & patches are preserved. +# wp-content/ is staged in /usr/src/wordpress-user/ and merged into the +# PVC on first boot (same as migrate mode). +# wp-config.php is saved separately so docker-entrypoint.sh can still +# inject WORDPRESS_DB_* env-vars when no config exists yet. +# ── migrate mode (no wp-admin) ── +# Only wp-content + config files are processed. RUN mkdir -p /usr/src/wordpress-user && \\ + if [ -d /tmp/user-content/wp-admin ]; then \\ + echo ">>> Full WordPress root (public_html) detected" && \\ + echo ">>> Copying wp-admin/ to /var/www/html/" && \\ + rm -rf /var/www/html/wp-admin && \\ + cp -a /tmp/user-content/wp-admin /var/www/html/wp-admin && \\ + echo ">>> Copying wp-includes/ to /var/www/html/" && \\ + rm -rf /var/www/html/wp-includes && \\ + cp -a /tmp/user-content/wp-includes /var/www/html/wp-includes && \\ + echo ">>> Copying root PHP files (index.php, wp-login.php, ...)" && \\ + find /tmp/user-content -maxdepth 1 -name "*.php" ! -name "wp-config.php" \\ + -exec cp {} /var/www/html/ \\; 2>/dev/null || true && \\ + echo ">>> Copying other root files/dirs (fonts, assets, etc.)" && \\ + for item in /tmp/user-content/*; do \\ + name=$(basename "$item"); \\ + case "$name" in \\ + wp-admin|wp-includes|wp-content|wp-config.php|.htaccess) ;; \\ + *.php) ;; \\ + *) \\ + if [ -f "$item" ]; then \\ + echo " root file: $name" && \\ + cp "$item" /var/www/html/; \\ + elif [ -d "$item" ]; then \\ + echo " root dir: $name/" && \\ + cp -a "$item" /var/www/html/; \\ + fi ;; \\ + esac; \\ + done; \\ + else \\ + echo ">>> wp-content / config files only (migrate mode)"; \\ + fi && \\ 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; \\ fi && \\ if [ -f /tmp/user-content/wp-config.php ]; then \\ - echo ">>> Copying custom wp-config.php" && \\ - cp /tmp/user-content/wp-config.php /var/www/html/wp-config.php; \\ + echo ">>> Saving custom wp-config.php" && \\ + cp /tmp/user-content/wp-config.php /usr/src/wordpress-user/wp-config.php; \\ fi && \\ if [ -f /tmp/user-content/.htaccess ]; then \\ echo ">>> Copying .htaccess" && \\ cp /tmp/user-content/.htaccess /var/www/html/.htaccess; \\ fi && \\ - find /tmp/user-content -maxdepth 1 -name "*.php" ! -name "wp-config.php" -exec cp {} /var/www/html/ \\\\; 2>/dev/null || true && \\ rm -rf /tmp/user-content && \\ echo ">>> WordPress user content staged" -# Custom entrypoint: merge staged wp-content into PVC on first run, then run WP +# 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 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 ' cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/'; \\ @@ -448,6 +853,14 @@ RUN { \\ 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 @@ -456,7 +869,8 @@ RUN { \\ RUN chown -R www-data:www-data /var/www/html EXPOSE 80 -CMD [${hasUploadedCode ? '"cloudhost-entrypoint.sh"' : '"apache2-foreground"'}] +${hasUploadedCode ? `ENTRYPOINT ["cloudhost-entrypoint.sh"] +CMD []` : `CMD ["apache2-foreground"]`} `; } @@ -469,26 +883,119 @@ CMD [${hasUploadedCode ? '"cloudhost-entrypoint.sh"' : '"apache2-foreground"'}] ): Promise { const startTime = Date.now(); const timeoutMs = timeoutSeconds * 1000; + let lastLoggedStatus = ''; while (Date.now() - startTime < timeoutMs) { + // ── Check Job status ── const job = await batchApi.readNamespacedJob(jobName, namespace); const status = job.body.status; if (status?.succeeded && status.succeeded > 0) { - return; // Build completed + this.logger.log(`Build job ${jobName} succeeded`); + return; } - if (status?.failed && status.failed > 0) { - // Try to get pod logs for more info + // Check if the Job has permanently failed (all retries exhausted) + const failedCondition = (status?.conditions || []).find( + (c) => c.type === 'Failed' && c.status === 'True', + ); + if (failedCondition) { const logs = await this.getBuildLogs(coreApi, jobName, namespace); throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`); } - // Wait 3 seconds before polling again - await new Promise((resolve) => setTimeout(resolve, 3000)); + // Safety net: if failures exceed backoffLimit and no pod is still running + const backoffLimit = job.body.spec?.backoffLimit ?? 0; + const failedCount = status?.failed ?? 0; + if (failedCount > backoffLimit) { + // Double-check: are there still active pods? + const activePods = (status as any)?.active ?? 0; + if (activePods === 0) { + const logs = await this.getBuildLogs(coreApi, jobName, namespace); + throw new Error( + `Build job ${jobName} failed: ${failedCount} failures exceeded backoffLimit=${backoffLimit}.\nLogs:\n${logs}`, + ); + } + } + + // Log intermediate pod failures (retries still available) + if (failedCount > 0) { + this.logger.warn( + `Build job ${jobName}: ${failedCount} pod failure(s), backoffLimit=${backoffLimit} — retrying...`, + ); + } + + // ── Check Pod status for early failure detection ── + try { + const pods = await coreApi.listNamespacedPod( + namespace, undefined, undefined, undefined, undefined, + `job-name=${jobName}`, + ); + + for (const pod of pods.body.items) { + const podName = pod.metadata?.name || 'unknown'; + const phase = pod.status?.phase; + + // Check all container statuses (init + regular) for stuck states + const allStatuses = [ + ...(pod.status?.initContainerStatuses || []), + ...(pod.status?.containerStatuses || []), + ]; + + for (const cs of allStatuses) { + const waiting = cs.state?.waiting; + if (waiting?.reason) { + const reason = waiting.reason; + const msg = waiting.message || ''; + + // These are unrecoverable — fail fast instead of waiting 10 minutes + const fatalReasons = [ + 'ErrImagePull', 'ImagePullBackOff', + 'CreateContainerConfigError', 'InvalidImageName', + 'CrashLoopBackOff', + ]; + + if (fatalReasons.includes(reason)) { + const logs = await this.getBuildLogs(coreApi, jobName, namespace); + throw new Error( + `Build pod ${podName} stuck: ${reason} — ${msg}\nLogs:\n${logs}`, + ); + } + + // Log non-fatal waiting states periodically + const statusKey = `${podName}:${cs.name}:${reason}`; + if (statusKey !== lastLoggedStatus) { + this.logger.warn(`Pod ${podName} container "${cs.name}": ${reason} — ${msg}`); + lastLoggedStatus = statusKey; + } + } + } + + // Log phase changes + const phaseKey = `${podName}:${phase}`; + if (phaseKey !== lastLoggedStatus && phase !== 'Succeeded') { + this.logger.log(`Build pod ${podName}: phase=${phase}`); + lastLoggedStatus = phaseKey; + } + } + } catch (podErr: any) { + // Don't fail the whole build just because pod status check failed + if (podErr.message?.includes('stuck:') || podErr.message?.includes('Build pod')) { + throw podErr; // Re-throw our own fatal errors + } + this.logger.warn(`Could not check pod status: ${podErr.message}`); + } + + // Wait 5 seconds before polling again + await new Promise((resolve) => setTimeout(resolve, 5000)); } - throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s`); + // Timeout — get logs for debugging + let logs = ''; + try { + logs = await this.getBuildLogs(coreApi, jobName, namespace); + } catch {} + throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s\nLogs:\n${logs}`); } private async getBuildLogs(