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
This commit is contained in:
keyhan
2026-04-22 16:43:59 +03:30
parent c42557a86b
commit 0e408d1baa
2 changed files with 766 additions and 76 deletions
+183
View File
@@ -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);
});
});
+583 -76
View File
@@ -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<void> {
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<void>((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<void> {
// 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<string>('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<string>('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/<userId>/<appId>/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<void> {
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(