Files
cloud-host/backend/src/build/build.service.ts
T
keyhan 22359be40e fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the
audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with
base schema, stateful service stability, safer Dockerfiles/git builds, and
platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-02 19:35:07 +03:30

1931 lines
74 KiB
TypeScript

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as k8s from '@kubernetes/client-node';
import * as fs from 'fs';
import * as path from 'path';
import { execFile, spawn, ChildProcess } from 'child_process';
import * as net from 'net';
import { promisify } from 'util';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
import { SourceStorageService } from '../storage/source-storage.service';
import {
detectDjangoSettingsModule,
detectGoBuildTarget,
detectShallowCsproj,
listArchiveEntries,
validateRuntimeFromArchive,
} from './runtime-detector';
const execFileAsync = promisify(execFile);
export class BuildCancelledError extends Error {
constructor() {
super('Build cancelled by user');
this.name = 'BuildCancelledError';
}
}
interface ActiveBuildSession {
cancelled: boolean;
applicationId?: string;
coreApi?: k8s.CoreV1Api;
batchApi?: k8s.BatchV1Api;
namespace?: string;
buildPodName?: string;
sourcePvcName?: string;
helperPodName?: string;
gitSecretName?: string;
processes: ChildProcess[];
socket?: net.Socket;
}
export interface BuildProgress {
phase: 'uploading' | 'building' | 'deploying' | 'done' | 'failed' | 'cancelled';
percent: number;
bytesUploaded?: number;
totalBytes?: number;
message?: string;
}
@Injectable()
export class BuildService {
private readonly logger = new Logger(BuildService.name);
private readonly progressMap = new Map<string, BuildProgress>();
private readonly activeBuilds = new Map<string, ActiveBuildSession>();
/**
* Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node
* with imagePullPolicy=IfNotPresent — avoids re-pulling the ~250MB image on every build.
*/
private readonly kanikoImage = process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
private registryService: RegistryService,
private progressStore: BuildProgressStore,
private sourceStorage: SourceStorageService,
) {}
/**
* Prefix Docker Hub base images with the configured mirror registry
* (BASE_IMAGE_REGISTRY), so generated Dockerfiles work on clusters that
* cannot reach docker.io. Images already pinned to another registry
* (gcr.io, mcr.microsoft.com, …) are returned unchanged.
*/
private baseImage(image: string): string {
const prefix = this.configService.get<string>('build.baseImageRegistry');
if (!prefix) return image;
const firstSegment = image.split('/')[0];
const hasRegistry = firstSegment.includes('.') || firstSegment.includes(':');
if (hasRegistry) return image;
return `${prefix}/${image}`;
}
/**
* Git branch names come from users and end up in a shell command — accept
* only conservative ref characters and reject anything option-like.
*/
private assertSafeGitBranch(branch: string): string {
const b = (branch || '').trim();
if (!b || b.length > 255 || b.startsWith('-') || b.includes('..') || !/^[A-Za-z0-9._/-]+$/.test(b)) {
throw new Error(`Invalid git branch name: "${branch}"`);
}
return b;
}
/**
* SSRF guard for user-supplied repo URLs: only http(s), no embedded
* credentials, and no loopback/link-local/private or cluster-internal hosts.
*/
private assertSafeGitUrl(gitUrl: string): void {
let url: URL;
try {
url = new URL(gitUrl);
} catch {
throw new Error(`Invalid git URL: "${gitUrl}"`);
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error(`Unsupported git URL protocol: "${url.protocol}" — only http(s) is allowed`);
}
if (url.username || url.password) {
throw new Error('Git URL must not contain embedded credentials — use the git token field instead');
}
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
const blockedHosts = ['localhost', 'metadata.google.internal'];
const blockedSuffixes = ['.local', '.localhost', '.internal', '.svc', '.svc.cluster.local', '.cluster.local'];
const isPrivateIPv4 =
/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
const isIPv6Internal = host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd');
if (
blockedHosts.includes(host) ||
blockedSuffixes.some((s) => host.endsWith(s)) ||
isPrivateIPv4 ||
isIPv6Internal ||
!host.includes('.')
) {
throw new Error(`Git URL host "${url.hostname}" is not allowed`);
}
}
private beginBuildSession(deploymentId: string, applicationId?: string): void {
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [], applicationId });
this.persistSession(deploymentId);
}
private getSession(deploymentId?: string): ActiveBuildSession | undefined {
if (!deploymentId) return undefined;
return this.activeBuilds.get(deploymentId);
}
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
const session = this.activeBuilds.get(deploymentId);
if (session) Object.assign(session, update);
this.persistSession(deploymentId);
}
/**
* Mirror the serializable part of the session to Redis, so interrupted
* builds can be detected and their cluster resources cleaned up after a
* backend restart (the in-memory map does not survive restarts).
*/
private persistSession(deploymentId: string): void {
const session = this.activeBuilds.get(deploymentId);
if (!session) return;
void this.progressStore.setSession({
deploymentId,
applicationId: session.applicationId,
namespace: session.namespace,
buildPodName: session.buildPodName,
sourcePvcName: session.sourcePvcName,
helperPodName: session.helperPodName,
gitSecretName: session.gitSecretName,
});
}
private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void {
const session = this.getSession(deploymentId);
if (!session) return;
session.processes.push(proc);
if (session.cancelled) {
try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
}
private registerSocket(deploymentId: string | undefined, socket: net.Socket): void {
const session = this.getSession(deploymentId);
if (!session) return;
if (session.socket) {
try {
session.socket.destroy();
} catch {
/* ignore */
}
}
session.socket = socket;
if (session.cancelled) {
try {
socket.destroy();
} catch {
/* ignore */
}
}
}
private throwIfCancelled(deploymentId?: string): void {
if (deploymentId && this.activeBuilds.get(deploymentId)?.cancelled) {
throw new BuildCancelledError();
}
}
private endBuildSession(deploymentId?: string): void {
if (deploymentId) {
this.activeBuilds.delete(deploymentId);
void this.progressStore.clearSession(deploymentId);
}
}
async cancelBuild(deploymentId: string): Promise<void> {
const session = this.activeBuilds.get(deploymentId);
if (!session) {
this.setProgress(deploymentId, {
phase: 'cancelled',
percent: 0,
message: 'Cancelled by user',
});
return;
}
session.cancelled = true;
this.logger.log(`Cancelling build for deployment ${deploymentId}`);
if (session.socket) {
try {
session.socket.destroy();
} catch {
/* ignore */
}
}
for (const proc of session.processes) {
try {
proc.kill('SIGKILL');
} catch {
/* ignore */
}
}
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName, gitSecretName } = session;
if (coreApi && namespace) {
const cleanup: Promise<unknown>[] = [];
if (helperPodName) {
cleanup.push(
coreApi
.deleteNamespacedPod({
name: helperPodName,
namespace,
gracePeriodSeconds: 0,
})
.catch(() => undefined),
);
}
if (buildPodName && batchApi) {
cleanup.push(
batchApi
.deleteNamespacedJob({
name: buildPodName,
namespace,
gracePeriodSeconds: 0,
propagationPolicy: 'Foreground',
})
.catch(() => undefined),
);
}
if (sourcePvcName) {
cleanup.push(
coreApi
.deleteNamespacedPersistentVolumeClaim({
name: sourcePvcName,
namespace,
})
.catch(() => undefined),
);
}
if (buildPodName) {
cleanup.push(
coreApi
.deleteNamespacedConfigMap({
name: `${buildPodName}-dockerfile`,
namespace,
})
.catch(() => undefined),
);
}
if (gitSecretName) {
cleanup.push(
coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace }).catch(() => undefined),
);
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
}
this.setProgress(deploymentId, {
phase: 'cancelled',
percent: 0,
message: 'Cancelled by user',
});
this.endBuildSession(deploymentId);
}
/** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */
async cleanupBuildResourcesForApp(app: Application): Promise<void> {
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const prefix = `build-${app.name}-`;
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const cleanup: Promise<unknown>[] = [];
const [pods, pvcs, jobs, configMaps, secrets] = await Promise.all([
coreApi.listNamespacedPod({ namespace: buildNamespace }),
coreApi.listNamespacedPersistentVolumeClaim({
namespace: buildNamespace,
}),
batchApi.listNamespacedJob({ namespace: buildNamespace }),
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
coreApi.listNamespacedSecret({ namespace: buildNamespace }),
]);
for (const pod of pods.items) {
const name = pod.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(
coreApi
.deleteNamespacedPod({
name,
namespace: buildNamespace,
gracePeriodSeconds: 0,
})
.catch(() => undefined),
);
}
}
for (const pvc of pvcs.items) {
const name = pvc.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(
coreApi
.deleteNamespacedPersistentVolumeClaim({
name,
namespace: buildNamespace,
})
.catch(() => undefined),
);
}
}
for (const job of jobs.items) {
const name = job.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(
batchApi
.deleteNamespacedJob({
name,
namespace: buildNamespace,
gracePeriodSeconds: 0,
propagationPolicy: 'Foreground',
})
.catch(() => undefined),
);
}
}
for (const cm of configMaps.items) {
const name = cm.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined));
}
}
for (const secret of secrets.items) {
const name = secret.metadata?.name || '';
if (name.startsWith(prefix)) {
cleanup.push(coreApi.deleteNamespacedSecret({ name, namespace: buildNamespace }).catch(() => undefined));
}
}
await Promise.all(cleanup);
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
}
async getProgress(deploymentId: string): Promise<BuildProgress | null> {
const local = this.progressMap.get(deploymentId);
if (local) return local;
const remote = await this.progressStore.get(deploymentId);
if (remote) this.progressMap.set(deploymentId, remote);
return remote;
}
setProgress(deploymentId: string | undefined, progress: BuildProgress): void {
if (!deploymentId) return;
this.progressMap.set(deploymentId, progress);
void this.progressStore.set(deploymentId, progress);
}
clearProgress(deploymentId: string): void {
this.progressMap.delete(deploymentId);
void this.progressStore.clear(deploymentId);
}
/**
* Builds a Docker image for the application using Kaniko inside K8s.
* Returns { imageUri, buildLog } — the full image URI and the build logs.
*/
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
const registryUrl = this.registryService.getRegistryUrl();
const buildNamespace = this.registryService.getBuildNamespace();
const tag = `${Date.now()}`;
const imageUri = this.registryService.buildImageReference(app.userId, app.name, tag);
this.logger.log(`Starting image build for ${app.name}${imageUri}`);
if (deploymentId) {
this.beginBuildSession(deploymentId, app.id);
}
const hasUploadedCode = !!app.codePath;
let localZipPath: string | null = null;
let cleanupSource: (() => void) | null = null;
if (hasUploadedCode) {
try {
const materialized = await this.sourceStorage.materializeToTempFile(app.codePath!);
localZipPath = materialized.path;
cleanupSource = materialized.cleanup;
await validateRuntimeFromArchive(app.runtime, localZipPath);
} catch (e) {
cleanupSource?.();
throw e;
}
}
const archiveEntries = hasUploadedCode && localZipPath ? await listArchiveEntries(localZipPath) : [];
// Determine Dockerfile based on runtime
const dockerfileContent = this.generateDockerfile(app, archiveEntries);
// Create Kaniko build pod
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
if (deploymentId) {
this.updateBuildSession(deploymentId, { buildPodName });
}
// Use the cluster's kubeconfig instead of default
const cluster = app.clusterId ? await this.clustersService.findOne(app.clusterId) : await this.clustersService.getDefault();
const kc = new k8s.KubeConfig();
kc.loadFromString(cluster.kubeconfig);
const coreApi = kc.makeApiClient(k8s.CoreV1Api);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
if (deploymentId) {
this.updateBuildSession(deploymentId, {
coreApi,
batchApi,
namespace: buildNamespace,
});
}
// Ensure the build namespace exists
await this.ensureNamespace(coreApi, buildNamespace);
this.throwIfCancelled(deploymentId);
// Determine if we have uploaded code or git URL
const hasGitUrl = !!app.gitUrl;
// Create ConfigMap with Dockerfile
const dockerfileConfigMap = {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: `${buildPodName}-dockerfile`,
namespace: buildNamespace,
},
data: {
Dockerfile: dockerfileContent,
},
};
// If we have uploaded code, create a PVC and upload via kubectl cp
let sourcePvcName: string | undefined;
// Secret holding the git token for private-repo clones (created lazily)
let gitSecretName: string | undefined;
if (hasUploadedCode && localZipPath) {
sourcePvcName = `${buildPodName}-source`;
if (deploymentId) {
this.updateBuildSession(deploymentId, { sourcePvcName });
}
const zipSize = await this.sourceStorage.getSize(app.codePath!);
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, localZipPath, pvcSizeGi, deploymentId);
cleanupSource?.();
cleanupSource = null;
}
// Build the Kaniko Job spec
// Always use dir context — init containers prepare /workspace/source
const kanikoArgs = [
'--dockerfile=/workspace/Dockerfile',
'--context=dir:///workspace/source',
`--destination=${imageUri}`,
'--cache=true',
`--cache-repo=${registryUrl}/${app.userId}/cache`,
'--insecure',
'--skip-tls-verify',
'--single-snapshot',
'--snapshot-mode=redo',
];
const volumes: any[] = [
{
name: 'docker-config',
secret: { secretName: 'registry-credentials' },
},
{
name: 'dockerfile',
configMap: {
name: `${buildPodName}-dockerfile`,
},
},
{
name: 'workspace',
emptyDir: {},
},
];
const initContainers: any[] = [];
if (hasUploadedCode && sourcePvcName) {
// Add the source PVC as a volume
volumes.push({
name: 'source-pvc',
persistentVolumeClaim: { claimName: sourcePvcName },
});
// Add init container that unzips the source code from PVC
initContainers.push({
name: 'unzip-source',
image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: [
'sh',
'-c',
`
apk add --no-cache unzip tar gzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /tmp/extract &&
cd /tmp/extract &&
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected gzip tarball" &&
tar xzf /source-pvc/source.zip
elif unzip -t /source-pvc/source.zip >/dev/null 2>&1; then
echo ">>> Detected zip archive" &&
unzip -q /source-pvc/source.zip
else
echo "ERROR: source archive is not a valid zip or tar.gz" && exit 1
fi &&
echo "--- Extracted contents ---" &&
ls -la /tmp/extract/ &&
mkdir -p /workspace-out/source &&
ITEMS=$(ls -1 /tmp/extract/ | head -5) &&
COUNT=$(ls -1 /tmp/extract/ | wc -l) &&
if [ "$COUNT" -eq 1 ] && [ -d "/tmp/extract/$ITEMS" ]; then
echo ">>> Single subfolder detected: $ITEMS — flattening to root" &&
cp -a /tmp/extract/$ITEMS/. /workspace-out/source/
else
echo ">>> Multiple items or files — copying as-is" &&
cp -a /tmp/extract/. /workspace-out/source/
fi &&
rm -rf /tmp/extract &&
echo "--- Final workspace contents ---" &&
ls -la /workspace-out/source/
`,
],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{
name: 'dockerfile',
mountPath: '/workspace/Dockerfile',
subPath: 'Dockerfile',
},
{ name: 'source-pvc', mountPath: '/source-pvc' },
],
});
} else if (hasGitUrl) {
// Validate user-controlled values before they get anywhere near a shell.
this.assertSafeGitUrl(app.gitUrl!);
const branch = this.assertSafeGitBranch(app.gitBranch || 'main');
// The token never appears in the command line or the clone URL — it is
// delivered via a Secret env var and handed to git through GIT_ASKPASS,
// so it can't leak through pod specs, `ps`, or job logs.
if (app.gitToken) {
gitSecretName = `${buildPodName}-git`;
if (deploymentId) this.updateBuildSession(deploymentId, { gitSecretName });
await coreApi.createNamespacedSecret({
namespace: buildNamespace!,
body: {
apiVersion: 'v1',
kind: 'Secret',
metadata: { name: gitSecretName, namespace: buildNamespace },
type: 'Opaque',
stringData: { GIT_TOKEN: app.gitToken },
},
});
}
// Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({
name: 'git-clone',
image: this.baseImage('alpine/git:2.43.0'),
imagePullPolicy: 'IfNotPresent',
env: [
{ name: 'GIT_URL', value: app.gitUrl! },
{ name: 'GIT_BRANCH', value: branch },
...(gitSecretName
? [
{
name: 'GIT_TOKEN',
valueFrom: { secretKeyRef: { name: gitSecretName, key: 'GIT_TOKEN' } },
},
]
: []),
],
command: [
'sh',
'-c',
`
set -e
if [ -n "\${GIT_TOKEN:-}" ]; then
printf '#!/bin/sh\\necho "$GIT_TOKEN"\\n' > /tmp/git-askpass.sh
chmod +x /tmp/git-askpass.sh
export GIT_ASKPASS=/tmp/git-askpass.sh
export GIT_TERMINAL_PROMPT=0
fi
echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL"
git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source
cp /dockerfile/Dockerfile /workspace-out/Dockerfile
echo ">>> Workspace contents:"
ls -la /workspace-out/source/
`,
],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' },
],
});
}
// Kaniko container volume mounts
const kanikoVolumeMounts: any[] = [
{ name: 'docker-config', mountPath: '/kaniko/.docker' },
{ name: 'workspace', mountPath: '/workspace' },
];
// If no uploaded code and no git, we need to prepare the workspace
if (!hasUploadedCode && !hasGitUrl) {
// For runtimes that don't need source (e.g. fresh WordPress),
// add an init container that creates empty source dir + copies Dockerfile
initContainers.push({
name: 'prepare-workspace',
image: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
command: [
'sh',
'-c',
`
mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
echo ">>> Prepared empty workspace for fresh install" &&
ls -la /workspace-out/
`,
],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' },
],
});
}
const buildJob: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: buildPodName,
namespace: buildNamespace,
},
spec: {
backoffLimit: 1,
ttlSecondsAfterFinished: 300,
template: {
spec: {
serviceAccountName: this.configService.get<string>('build.serviceAccount'),
initContainers: initContainers.length > 0 ? initContainers : undefined,
containers: [
{
name: 'kaniko',
image: this.kanikoImage,
imagePullPolicy: 'IfNotPresent',
args: kanikoArgs,
volumeMounts: kanikoVolumeMounts,
resources: {
requests: {
cpu: this.configService.get<string>('build.kaniko.cpuRequest') || '500m',
memory: this.configService.get<string>('build.kaniko.memoryRequest') || '1Gi',
},
limits: {
cpu: this.configService.get<string>('build.kaniko.cpuLimit') || '2',
memory: this.configService.get<string>('build.kaniko.memoryLimit') || '4Gi',
},
},
},
],
restartPolicy: 'Never',
volumes,
},
},
},
};
try {
const t0 = Date.now();
await coreApi.createNamespacedConfigMap({
namespace: buildNamespace!,
body: dockerfileConfigMap,
});
this.logger.log(`[timing] ConfigMap created in ${Date.now() - t0}ms`);
const t1 = Date.now();
await batchApi.createNamespacedJob({
namespace: buildNamespace!,
body: buildJob,
});
this.logger.log(`[timing] Job created in ${Date.now() - t1}ms`);
// Wait for build to complete
this.setProgress(deploymentId, {
phase: 'building',
percent: 15,
message: 'Building Docker image...',
});
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600, deploymentId);
// Capture build logs on success
let buildLog = '';
try {
buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
} catch {}
this.logger.log(`Build completed successfully: ${imageUri}`);
return { imageUri, buildLog };
} catch (error: any) {
if (error instanceof BuildCancelledError || error?.name === 'BuildCancelledError') {
throw error;
}
// Try to get build logs for debugging
let buildLog = '';
try {
buildLog = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
this.logger.error(`Build logs for ${buildPodName}:\n${buildLog}`);
} catch {}
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
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({
name: sourcePvcName,
namespace: 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({
name: `${buildPodName}-dockerfile`,
namespace: buildNamespace!,
});
} catch (e: any) {
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
}
// Clean up git-token Secret
if (gitSecretName) {
try {
await coreApi.deleteNamespacedSecret({
name: gitSecretName,
namespace: buildNamespace!,
});
} catch (e: any) {
this.logger.warn(`Failed to clean up git Secret: ${e.message}`);
}
}
this.endBuildSession(deploymentId);
cleanupSource?.();
}
}
/**
* Upload a local file to the helper pod using kubectl cp with progress tracking.
* kubectl cp uses tar over the k8s exec API — reliable for any file size.
*/
private streamFileToHelperPod(kubeconfig: string, namespace: string, podName: string, filePath: string, fileSize: number, deploymentId?: string): Promise<void> {
const maxAttempts = 3;
const runOnce = () =>
new Promise<void>((resolve, reject) => {
this.throwIfCancelled(deploymentId);
const kubectl = spawn('kubectl', ['--kubeconfig', kubeconfig, 'cp', filePath, `${namespace}/${podName}:/data/source.zip`, '-c', 'helper', '--retries', '3'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
this.registerProcess(deploymentId, kubectl);
let stderr = '';
kubectl.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString();
});
// Track progress by polling remote file size
let progressTimer: NodeJS.Timeout | undefined;
const pollProgress = () => {
execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip 2>/dev/null || echo 0'], {
timeout: 10_000,
})
.then(({ stdout }) => {
const remoteSize = parseInt(stdout.trim(), 10) || 0;
const percent = Math.min(99, Math.round((remoteSize / fileSize) * 100));
this.setProgress(deploymentId, {
phase: 'uploading',
percent,
bytesUploaded: remoteSize,
totalBytes: fileSize,
message: `Uploading to cluster... ${percent}%`,
});
})
.catch(() => {
/* polling failure is non-fatal */
});
};
progressTimer = setInterval(pollProgress, 3000);
pollProgress();
kubectl.on('error', (err) => {
clearInterval(progressTimer);
reject(new Error(`kubectl cp spawn error: ${err.message}`));
});
kubectl.on('close', (code) => {
clearInterval(progressTimer);
if (code === 0) resolve();
else reject(new Error(`kubectl cp failed (code ${code}): ${stderr.trim()}`));
});
});
return (async () => {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
this.throwIfCancelled(deploymentId);
if (attempt > 1) {
this.logger.warn(`Retrying source upload (attempt ${attempt}/${maxAttempts})...`);
await execFileAsync('kubectl', ['--kubeconfig', kubeconfig, 'exec', '-n', namespace, podName, '-c', 'helper', '--', 'rm', '-f', '/data/source.zip'], { timeout: 15_000 }).catch(
() => undefined,
);
this.setProgress(deploymentId, {
phase: 'uploading',
percent: 0,
bytesUploaded: 0,
totalBytes: fileSize,
message: `Retrying upload (attempt ${attempt})...`,
});
}
await runOnce();
return;
} catch (err) {
if (err instanceof BuildCancelledError || (err as Error)?.name === 'BuildCancelledError') throw err;
if (attempt === maxAttempts) throw err;
this.logger.warn(`Upload attempt ${attempt} failed: ${(err as Error).message}`);
}
}
})();
}
/**
* Upload source zip to K8s via PVC + helper pod.
* This handles files of any size (unlike Secret/ConfigMap which are limited to ~1MB).
*/
private async uploadSourceViaPVC(kc: k8s.KubeConfig, coreApi: k8s.CoreV1Api, namespace: string, pvcName: string, zipPath: string, sizeGi: number, deploymentId?: string): Promise<void> {
const t0 = Date.now();
const helperPodName = `${pvcName}-helper`;
const zipSize = fs.statSync(zipPath).size;
if (deploymentId) {
this.updateBuildSession(deploymentId, { helperPodName });
}
this.logger.log(`Uploading source via PVC (${(zipSize / 1024 / 1024).toFixed(1)} MB) → ${pvcName}`);
// 1. Create PVC
await coreApi.createNamespacedPersistentVolumeClaim({
namespace,
body: {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: pvcName, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
// Explicit StorageClass — don't rely on a cluster default existing
storageClassName: this.configService.get<string>('platform.storageClass') || undefined,
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: this.baseImage('alpine:3.19'),
imagePullPolicy: 'IfNotPresent',
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, body: helperPod });
// 3. Wait for helper pod to be Running
const podTimeout = 120_000; // 2 minutes
const podStart = Date.now();
while (Date.now() - podStart < podTimeout) {
this.throwIfCancelled(deploymentId);
const pod = await coreApi.readNamespacedPod({
name: helperPodName,
namespace,
});
const phase = pod.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. Upload the zip via kubectl cp (tar-based, reliable for any size).
const t2 = Date.now();
this.setProgress(deploymentId, {
phase: 'uploading',
percent: 0,
bytesUploaded: 0,
totalBytes: zipSize,
message: 'Uploading source to cluster...',
});
await this.streamFileToHelperPod(tmpKubeconfig, namespace, helperPodName, zipPath, zipSize, deploymentId);
this.logger.log(`[timing] Source stream upload completed in ${Date.now() - t2}ms (${(zipSize / 1024 / 1024).toFixed(1)} MB)`);
this.setProgress(deploymentId, {
phase: 'uploading',
percent: 100,
bytesUploaded: zipSize,
totalBytes: zipSize,
message: 'Upload complete, verifying...',
});
// 5b. Verify the file was written correctly (exact size)
const { stdout: sizeStr } = await execFileAsync(
'kubectl',
['--kubeconfig', tmpKubeconfig, 'exec', '-n', namespace, helperPodName, '-c', 'helper', '--', 'sh', '-c', 'wc -c < /data/source.zip'],
{ timeout: 30_000 },
);
const remoteSize = parseInt(sizeStr.trim(), 10);
if (isNaN(remoteSize) || remoteSize !== zipSize) {
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({
name: helperPodName,
namespace,
gracePeriodSeconds: 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({ name: helperPodName, namespace });
// Pod still exists — wait
await new Promise((r) => setTimeout(r, 2000));
} catch (err: any) {
if (err.code === 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({ name: namespace });
} catch (err: any) {
if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Namespace "${namespace}" not found — creating it`);
await coreApi.createNamespace({
body: { 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({ name: saName, namespace });
} catch (err: any) {
if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`ServiceAccount "${saName}" not found in "${namespace}" — creating it`);
await coreApi.createNamespacedServiceAccount({
namespace,
body: { 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({
name: registrySecretName,
namespace,
});
} catch (err: any) {
if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`);
await coreApi.createNamespacedSecret({
namespace,
body: {
metadata: { name: registrySecretName, namespace },
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
},
},
});
} else {
throw err;
}
}
}
private generateDockerfile(app: Application, archiveEntries: string[] = []): string {
switch (app.runtime) {
case AppRuntime.NODEJS:
return this.nodeDockerfile(app);
case AppRuntime.LARAVEL:
return this.laravelDockerfile(app);
case AppRuntime.WORDPRESS:
return this.wordpressDockerfile(app);
case AppRuntime.GO:
return this.goDockerfile(app, archiveEntries);
case AppRuntime.PHP:
return this.phpDockerfile(app);
case AppRuntime.PYTHON:
return this.pythonDockerfile(app);
case AppRuntime.DJANGO:
return this.djangoDockerfile(app, archiveEntries);
case AppRuntime.DOTNET:
return this.dotnetDockerfile(app, archiveEntries);
default:
throw new Error(`Unsupported runtime: ${app.runtime}`);
}
}
private nodeDockerfile(app: Application): string {
const port = app.port || 3000;
const nodeVersion = app.runtimeVersion || '20';
return `# --- Build stage ---
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder
WORKDIR /app
COPY package*.json ./
# Reproducible install from the lockfile when present
RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\
&& npm cache clean --force
COPY . .
# Auto-detect Next.js and enable standalone output
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 the build script when one exists — and FAIL the image build if it fails,
# instead of silently shipping a broken image.
RUN if node -e "const s=(require('./package.json').scripts||{});process.exit(s.build?0:1)"; then \\
echo ">>> Running build script" && npm run build; \\
else \\
echo ">>> No build script defined — skipping"; \\
fi
# 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 ${this.baseImage(`node:${nodeVersion}-alpine`)} AS runner
WORKDIR /app
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
# Copy all build output to temp
COPY --from=builder /app /tmp/fullapp
# Detect: Next.js standalone vs regular Node.js
RUN if [ -d /tmp/fullapp/.next/standalone ]; then \\
echo ">>> Next.js standalone mode"; \\
cp -a /tmp/fullapp/.next/standalone/. .; \\
mkdir -p .next/static; \\
[ -d /tmp/fullapp/.next/static ] && cp -a /tmp/fullapp/.next/static/. .next/static/; \\
[ -d /tmp/fullapp/public ] && cp -a /tmp/fullapp/public ./public; \\
echo "standalone" > /app/.mode; \\
else \\
echo ">>> Regular Node.js app"; \\
cp -a /tmp/fullapp/. .; \\
echo "regular" > /app/.mode; \\
fi && rm -rf /tmp/fullapp
USER appuser
ENV PORT=${port}
ENV HOSTNAME=0.0.0.0
EXPOSE ${port}
CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f server.js ]; then node server.js; else npm start; fi"]
`;
}
private laravelDockerfile(app: Application): string {
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `# --- Build stage (match production PHP version for Composer) ---
FROM ${this.baseImage(`php:${phpVersion}-cli-alpine`)} AS composer
RUN apk add --no-cache git unzip
COPY --from=${this.baseImage('composer:2')} /usr/bin/composer /usr/bin/composer
WORKDIR /app
COPY composer.json composer.lock* ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
COPY . .
RUN composer dump-autoload --optimize --no-dev --no-scripts
# --- Production stage ---
FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
# Laravel needs bcmath/gd/intl/zip beyond the built-in set; pdo_pgsql is built
# properly against libpq instead of being silently skipped.
RUN apk add --no-cache nginx supervisor curl openssl icu-libs libzip libpng libjpeg-turbo freetype postgresql-libs \\
&& apk add --no-cache --virtual .build-deps icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev postgresql-dev \\
&& docker-php-ext-configure gd --with-jpeg --with-freetype \\
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache bcmath zip gd intl exif pcntl \\
&& apk del .build-deps
WORKDIR /var/www/html
COPY --from=composer /app .
# 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 echo '#!/bin/sh' > /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'set -e' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'cd /var/www/html' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views storage/app/public bootstrap/cache' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'chown -R www-data:www-data storage bootstrap/cache' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'if [ -z "$APP_KEY" ] || [ "$APP_KEY" = "null" ]; then export APP_KEY="base64:$(openssl rand -base64 32)"; fi' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan config:clear || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan cache:clear || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan migrate --force --no-interaction || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan storage:link || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan config:cache || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan route:cache || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'php artisan view:cache || true' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
echo 'exec /usr/bin/supervisord -c /etc/supervisord.conf' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\
chmod +x /usr/local/bin/cloudhost-laravel-entrypoint.sh
EXPOSE ${port}
CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"]
`;
}
private wordpressDockerfile(app: Application): string {
const wpVersion = app.runtimeVersion || '6.7';
const phpVersion = app.phpVersion || '8.3';
const hasUploadedCode = !!app.codePath;
return `FROM ${this.baseImage(`wordpress:${wpVersion}-php${phpVersion}-apache`)}
# Install additional PHP extensions commonly needed by WordPress
RUN docker-php-ext-install opcache
# Enable Apache mod_rewrite for pretty permalinks
RUN a2enmod rewrite
# Increase PHP upload limits for WordPress media
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 user's custom WordPress files
COPY . /tmp/user-content
# 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; \\
elif [ -d /tmp/user-content/themes ] || [ -d /tmp/user-content/plugins ] || [ -d /tmp/user-content/uploads ]; then \\
echo ">>> Staging loose themes/plugins/uploads into wp-content..." && \\
mkdir -p /usr/src/wordpress-user/wp-content && \\
[ -d /tmp/user-content/themes ] && cp -a /tmp/user-content/themes /usr/src/wordpress-user/wp-content/ || true && \\
[ -d /tmp/user-content/plugins ] && cp -a /tmp/user-content/plugins /usr/src/wordpress-user/wp-content/ || true && \\
[ -d /tmp/user-content/uploads ] && cp -a /tmp/user-content/uploads /usr/src/wordpress-user/wp-content/ || true; \\
fi && \\
# wp-config.php is intentionally NOT copied — docker-entrypoint.sh generates it
# from WORDPRESS_DB_* env vars so credentials always match the deployed database.
if [ -f /tmp/user-content/.htaccess ]; then \\
echo ">>> Copying .htaccess" && \\
cp /tmp/user-content/.htaccess /var/www/html/.htaccess; \\
fi && \\
rm -rf /tmp/user-content && \\
echo ">>> WordPress user content staged"
# Custom entrypoint:
# 1. Merge staged wp-content into the PVC mount (every start — idempotent)
# 2. Hand off to official docker-entrypoint.sh which creates wp-config.php
# from WORDPRESS_DB_* env vars (never use uploaded wp-config with old credentials)
RUN { \\
echo '#!/bin/bash'; \\
echo 'set -e'; \\
echo ''; \\
echo '# ── Merge user wp-content into PVC ──'; \\
echo 'if [ -d /usr/src/wordpress-user/wp-content ]; then'; \\
echo ' echo ">>> Merging user wp-content into PVC..."'; \\
echo ' mkdir -p /var/www/html/wp-content'; \\
echo ' cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/'; \\
echo ' chown -R www-data:www-data /var/www/html/wp-content'; \\
echo ' echo ">>> User wp-content merged successfully"'; \\
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
`
}
# Set proper ownership
RUN chown -R www-data:www-data /var/www/html
EXPOSE 80
${
hasUploadedCode
? `ENTRYPOINT ["cloudhost-entrypoint.sh"]
CMD []`
: `CMD ["apache2-foreground"]`
}
`;
}
// ─── Go Dockerfile ─────────────────────────────────────────────────
private goDockerfile(app: Application, archiveEntries: string[] = []): string {
const goVersion = app.runtimeVersion || '1.22';
const port = app.port || 8080;
const buildTarget = detectGoBuildTarget(archiveEntries);
return `# --- Build stage ---
FROM ${this.baseImage(`golang:${goVersion}-alpine`)} AS builder
WORKDIR /app
# Install git for fetching dependencies
RUN apk add --no-cache git
# Copy go mod files first for better caching
COPY go.mod go.sum* ./
RUN go mod download || true
# Copy source code
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget}
# Collect optional runtime asset dirs — COPY has no shell so "|| true" is not
# valid there; stage them in the builder instead.
RUN mkdir -p /assets \\
&& for d in static templates public; do [ -d "$d" ] && cp -r "$d" /assets/ || true; done
# --- Production stage ---
FROM ${this.baseImage('alpine:3.19')}
WORKDIR /app
# Add CA certificates for HTTPS requests
RUN apk --no-cache add ca-certificates tzdata
# Create non-root user
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
# Copy the binary and any staged asset dirs from the builder
COPY --from=builder /app/main .
COPY --from=builder /assets/ ./
# Create data directory for persistent storage
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PORT=${port}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
CMD ["./main"]
`;
}
// ─── PHP (Plain) Dockerfile ────────────────────────────────────────
private phpDockerfile(app: Application): string {
const phpVersion = app.phpVersion || '8.3';
const port = app.port || 80;
return `FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
# Install common PHP extensions (pdo_pgsql built properly against libpq)
RUN apk add --no-cache nginx supervisor curl postgresql-libs libpng libjpeg-turbo freetype \\
&& apk add --no-cache --virtual .build-deps postgresql-dev libpng-dev libjpeg-turbo-dev freetype-dev \\
&& docker-php-ext-configure gd --with-freetype --with-jpeg \\
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache gd \\
&& apk del .build-deps
WORKDIR /var/www/html
COPY . .
# Generate nginx config
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;' >> /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
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 '[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 '[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
# Use custom configs if provided
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
# Create upload and data directories
RUN mkdir -p /var/www/html/uploads /var/www/html/data \\
&& chown -R www-data:www-data /var/www/html
EXPOSE ${port}
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
`;
}
// ─── Python Dockerfile ─────────────────────────────────────────────
private pythonDockerfile(app: Application): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
return `# --- Build stage ---
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev \\
&& rm -rf /var/lib/apt/lists/*
# Install dependencies from requirements.txt or pyproject.toml. A failing
# install FAILS the build — no silent fallback that hides missing deps.
COPY . .
RUN if [ -f requirements.txt ]; then \\
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
elif [ -f pyproject.toml ]; then \\
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
else \\
echo ">>> No requirements.txt or pyproject.toml — installing default flask+gunicorn" \\
&& pip install --no-cache-dir --user flask gunicorn; \\
fi
# --- Production stage ---
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
libpq5 curl \\
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
# Copy installed packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY . .
# Create data directory
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
ENV PYTHONUNBUFFERED=1
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \\
CMD curl -f http://localhost:${port}/health || exit 1
# Auto-detect: Flask, FastAPI, or plain Python
CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicorn main:app --host 0.0.0.0 --port ${port}; elif grep -qi flask main.py; then exec gunicorn -w 4 -b 0.0.0.0:${port} main:app; else exec python main.py; fi; elif [ -f app.py ]; then if grep -qi fastapi app.py; then exec uvicorn app:app --host 0.0.0.0 --port ${port}; elif grep -qi flask app.py; then exec gunicorn -w 4 -b 0.0.0.0:${port} app:app; else exec python app.py; fi; else exec gunicorn -w 4 -b 0.0.0.0:${port} app:app; fi"
`;
}
// ─── Django Dockerfile ─────────────────────────────────────────────
private djangoDockerfile(app: Application, archiveEntries: string[] = []): string {
const pythonVersion = app.runtimeVersion || '3.12';
const port = app.port || 8000;
const settingsModule = detectDjangoSettingsModule(archiveEntries);
return `# --- Build stage ---
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
WORKDIR /app
# Install build dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
build-essential libpq-dev default-libmysqlclient-dev pkg-config \\
&& rm -rf /var/lib/apt/lists/*
# Install dependencies from requirements.txt or pyproject.toml. A failing
# install FAILS the build — no silent fallback that hides missing deps.
COPY . .
RUN if [ -f requirements.txt ]; then \\
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
elif [ -f pyproject.toml ]; then \\
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
else \\
echo ">>> No requirements.txt or pyproject.toml — installing Django defaults" \\
&& pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient; \\
fi
# --- Production stage ---
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
WORKDIR /app
# Install runtime dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \\
libpq5 default-libmysqlclient-dev curl \\
&& rm -rf /var/lib/apt/lists/*
# Create non-root user
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
# Copy installed packages from builder
COPY --from=builder /root/.local /home/appuser/.local
# Copy application code
COPY . .
# Create directories for static files and media
RUN mkdir -p /app/staticfiles /app/media /app/data \\
&& chown -R appuser:appgroup /app
USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=${settingsModule}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
CMD curl -f http://localhost:${port}/health/ || curl -f http://localhost:${port}/ || exit 1
# Auto-detect project structure and run migrations + collectstatic
CMD sh -c "\\
PROJECT_NAME=\\$(find . -maxdepth 2 -name 'wsgi.py' | head -1 | cut -d'/' -f2) && \\
if [ -z \\\"\\$PROJECT_NAME\\\" ]; then PROJECT_NAME='config'; fi && \\
echo \\\"Django project: \\$PROJECT_NAME\\\" && \\
python manage.py migrate --noinput 2>/dev/null || true && \\
python manage.py collectstatic --noinput 2>/dev/null || true && \\
exec gunicorn \\$PROJECT_NAME.wsgi:application --bind 0.0.0.0:${port} --workers 4 --threads 2 \\
"
`;
}
// ─── .NET Dockerfile ───────────────────────────────────────────────
private dotnetDockerfile(app: Application, archiveEntries: string[] = []): string {
const dotnetVersion = app.runtimeVersion || '8.0';
const port = app.port || 5000;
const csprojHint = detectShallowCsproj(archiveEntries);
const csprojFind = csprojHint
? `CSPROJ="${csprojHint}"`
: `CSPROJ=$(find . -maxdepth 3 -name '*.csproj' | head -1)`;
return `# --- Build stage ---
FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
WORKDIR /src
# Copy source and locate project file (supports nested csproj layouts)
COPY . .
RUN ${csprojFind} && \\
test -n "$CSPROJ" && \\
dotnet restore "$CSPROJ" && \\
dotnet publish "$CSPROJ" -c Release -o /app/publish
# --- Production stage ---
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
WORKDIR /app
# Create non-root user
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
# Copy published app
COPY --from=build /app/publish .
# Create data directory
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
USER appuser
ENV ASPNETCORE_URLS=http://+:${port}
ENV DOTNET_RUNNING_IN_CONTAINER=true
ENV ASPNETCORE_ENVIRONMENT=Production
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
CMD curl -f http://localhost:${port}/health || curl -f http://localhost:${port}/ || exit 1
# Auto-detect entry point DLL
CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' ! -name '*.runtimeconfig.dll' | head -1) && dotnet $DLL"]
`;
}
/**
* Whether a K8s API error is a transient connectivity/availability blip that
* should be retried rather than failing the operation. Covers socket-level
* errors, request timeouts (incl. the apiserver "request did not complete
* within the allotted timeout" message), DNS hiccups and 5xx/429 responses.
*/
private isTransientK8sError(err: any): boolean {
const statusCode = err?.statusCode ?? err?.response?.statusCode ?? err?.body?.code;
if (typeof statusCode === 'number' && (statusCode >= 500 || statusCode === 429)) {
return true;
}
const haystack = [err?.code, err?.message, err?.body?.message, err?.cause?.code].filter(Boolean).join(' ');
return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EPIPE|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|timed? ?out|allotted timeout|did not complete|Client network socket disconnected/i.test(
haystack,
);
}
private async waitForJobCompletion(batchApi: k8s.BatchV1Api, coreApi: k8s.CoreV1Api, jobName: string, namespace: string, timeoutSeconds: number, deploymentId?: string): Promise<void> {
const startTime = Date.now();
const timeoutMs = timeoutSeconds * 1000;
let lastLoggedStatus = '';
while (Date.now() - startTime < timeoutMs) {
this.throwIfCancelled(deploymentId);
const elapsed = Date.now() - startTime;
const buildPercent = Math.min(90, 15 + Math.round((elapsed / timeoutMs) * 75));
this.setProgress(deploymentId, {
phase: 'building',
percent: buildPercent,
message: 'Building Docker image...',
});
// ── Check Job status (with retry for transient connection errors) ──
let job: k8s.V1Job;
try {
job = await batchApi.readNamespacedJob({ name: jobName, namespace });
} catch (pollErr: any) {
// The Kaniko job keeps running independently of these status polls.
// A single API blip (timeout, reset, 5xx, DNS) must NOT abort a build
// that is still progressing — just retry on the next poll tick.
if (this.isTransientK8sError(pollErr)) {
const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown';
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${detail} — retrying in 5s`);
await new Promise((r) => setTimeout(r, 5000));
continue;
}
throw pollErr;
}
const status = job.status;
if (status?.succeeded && status.succeeded > 0) {
this.logger.log(`Build job ${jobName} succeeded`);
return;
}
// 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}`);
}
// Safety net: if failures exceed backoffLimit and no pod is still running
const backoffLimit = job.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,
labelSelector: `job-name=${jobName}`,
});
for (const pod of pods.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));
}
// 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}`);
}
/**
* Live build logs for an in-progress build, read straight from the running
* build pod (init + kaniko containers). Returns null when there is no active
* build session for this deployment (e.g. build already finished/cleaned up),
* so callers can fall back to the persisted build log.
*/
async getLiveBuildLog(deploymentId: string): Promise<string | null> {
const session = this.activeBuilds.get(deploymentId);
if (!session?.coreApi || !session.namespace || !session.buildPodName) {
return null;
}
try {
return await this.getBuildLogs(session.coreApi, session.buildPodName, session.namespace);
} catch {
return null;
}
}
private async getBuildLogs(coreApi: k8s.CoreV1Api, jobName: string, namespace: string): Promise<string> {
try {
const pods = await coreApi.listNamespacedPod({
namespace,
labelSelector: `job-name=${jobName}`,
});
if (pods.items.length === 0) {
return 'No pods found for build job.';
}
const podName = pods.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
// Get logs from all containers (init + kaniko)
let allLogs = '';
const containers = [...(pods.items[0].spec?.initContainers || []), ...(pods.items[0].spec?.containers || [])];
for (const container of containers) {
try {
const logResponse = await coreApi.readNamespacedPodLog({
name: podName,
namespace,
container: container.name,
tailLines: 500,
});
allLogs += `\n--- ${container.name} ---\n${logResponse}`;
} catch {
allLogs += `\n--- ${container.name} --- (no logs available)`;
}
}
return allLogs;
} catch (e: any) {
return `Failed to retrieve logs: ${e.message}`;
}
}
}