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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user