Files
cloud-host/backend/src/build/build.service.ts
T
keyhan 33be1649c4 init
2026-04-05 15:22:01 +03:30

426 lines
14 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 { Application } from '../applications/entities/application.entity';
import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
@Injectable()
export class BuildService {
private readonly logger = new Logger(BuildService.name);
constructor(
private configService: ConfigService,
private clustersService: ClustersService,
) {}
/**
* Builds a Docker image for the application using Kaniko inside K8s.
* Returns the full image URI (registry/repo:tag).
*/
async buildImage(app: Application): Promise<string> {
// Internal registry (used by Kaniko inside K8s for pushing)
const internalRegistryUrl = this.configService.get<string>('registry.url') || 'registry.cloudhost-builds.svc.cluster.local:5000';
// External registry URL (used by kubelet for pulling — NodePort or external)
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
const buildNamespace = this.configService.get<string>('build.namespace') || 'cloudhost-builds';
const tag = `${Date.now()}`;
const pushImageUri = `${internalRegistryUrl}/${app.userId}/${app.name}:${tag}`;
const pullImageUri = `${pullRegistryUrl}/${app.userId}/${app.name}:${tag}`;
this.logger.log(`Starting image build for ${app.name} → push: ${pushImageUri}, pull: ${pullImageUri}`);
// Determine Dockerfile based on runtime
const dockerfileContent = this.generateDockerfile(app);
// Create Kaniko build pod
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
// 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);
// Determine if we have uploaded code or git URL
const codePath = app.codePath ? path.resolve(app.codePath) : null;
const hasUploadedCode = codePath && fs.existsSync(codePath);
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 ConfigMap with the zip as base64
let sourceConfigMapName: string | undefined;
if (hasUploadedCode) {
const zipBuffer = fs.readFileSync(codePath!);
const zipBase64 = zipBuffer.toString('base64');
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,
},
};
await coreApi.createNamespacedSecret(buildNamespace!, sourceSecret);
this.logger.log(`Created source secret: ${sourceConfigMapName} (${(zipBuffer.length / 1024).toFixed(1)} KB)`);
}
// Build the Kaniko Job spec
// Always use dir context — init containers prepare /workspace/source
const kanikoArgs = [
'--dockerfile=/workspace/Dockerfile',
'--context=dir:///workspace/source',
`--destination=${pushImageUri}`,
'--cache=true',
`--cache-repo=${internalRegistryUrl}/${app.userId}/cache`,
'--insecure',
'--skip-tls-verify',
];
const volumes: any[] = [
{
name: 'docker-config',
secret: { secretName: 'registry-credentials' },
},
{
name: 'dockerfile',
configMap: {
name: `${buildPodName}-dockerfile`,
},
},
{
name: 'workspace',
emptyDir: {},
},
];
const initContainers: any[] = [];
if (hasUploadedCode && sourceConfigMapName) {
// Add the source secret as a volume
volumes.push({
name: 'source-zip',
secret: { secretName: sourceConfigMapName },
});
// Add init container that unzips the source code
initContainers.push({
name: 'unzip-source',
image: 'alpine:3.19',
command: ['sh', '-c', `
apk add --no-cache unzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /workspace-out/source &&
cd /workspace-out/source &&
unzip /source/source.zip &&
ls -la /workspace-out/source/
`],
volumeMounts: [
{ name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/workspace/Dockerfile', subPath: 'Dockerfile' },
{ name: 'source-zip', mountPath: '/source' },
],
});
} else if (hasGitUrl) {
// Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({
name: 'git-clone',
image: 'alpine/git:2.43.0',
command: ['sh', '-c', `
echo ">>> Cloning ${app.gitUrl}" &&
git clone --depth 1 ${app.gitUrl} /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, mount dockerfile directly
if (!hasUploadedCode && !hasGitUrl) {
kanikoVolumeMounts.push({
name: 'dockerfile',
mountPath: '/workspace/Dockerfile',
subPath: 'Dockerfile',
});
}
const buildJob: k8s.V1Job = {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: {
name: buildPodName,
namespace: buildNamespace,
},
spec: {
backoffLimit: 2,
ttlSecondsAfterFinished: 300,
template: {
spec: {
serviceAccountName: this.configService.get<string>('build.serviceAccount'),
initContainers: initContainers.length > 0 ? initContainers : undefined,
containers: [
{
name: 'kaniko',
image: 'gcr.io/kaniko-project/executor:latest',
args: kanikoArgs,
volumeMounts: kanikoVolumeMounts,
resources: {
requests: { cpu: '500m', memory: '1Gi' },
limits: { cpu: '2', memory: '4Gi' },
},
},
],
restartPolicy: 'Never',
volumes,
},
},
},
};
try {
await coreApi.createNamespacedConfigMap(buildNamespace!, dockerfileConfigMap);
await batchApi.createNamespacedJob(buildNamespace!, buildJob);
// Wait for build to complete
await this.waitForJobCompletion(batchApi, coreApi, buildPodName, buildNamespace!, 600);
this.logger.log(`Build completed successfully: ${pullImageUri}`);
return pullImageUri;
} catch (error: any) {
// Try to get build logs for debugging
try {
const logs = await this.getBuildLogs(coreApi, buildPodName, buildNamespace!);
this.logger.error(`Build logs for ${buildPodName}:\n${logs}`);
} catch {}
this.logger.error(`Build failed for ${app.name}:`, error.body || error.message);
throw new Error(`Image build failed: ${error.body?.message || error.message}`);
}
}
private generateDockerfile(app: Application): string {
switch (app.runtime) {
case AppRuntime.NODEJS:
return this.nodeDockerfile(app);
case AppRuntime.LARAVEL:
return this.laravelDockerfile(app);
default:
throw new Error(`Unsupported runtime: ${app.runtime}`);
}
}
private nodeDockerfile(app: Application): string {
const port = app.port || 3000;
return `# --- Build stage ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi && 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 npm run build 2>/dev/null || true
# --- Production stage ---
FROM node:20-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 {
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 . .
RUN composer dump-autoload --optimize --no-dev
# --- Production stage ---
FROM php:8.3-fpm-alpine
RUN apk add --no-cache nginx supervisor \\
&& docker-php-ext-install pdo pdo_mysql pdo_pgsql opcache
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
RUN php artisan config:cache && php artisan route:cache && php artisan view:cache || true
EXPOSE ${app.port || 8000}
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
`;
}
private async waitForJobCompletion(
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
jobName: string,
namespace: string,
timeoutSeconds: number,
): Promise<void> {
const startTime = Date.now();
const timeoutMs = timeoutSeconds * 1000;
while (Date.now() - startTime < timeoutMs) {
const job = await batchApi.readNamespacedJob(jobName, namespace);
const status = job.body.status;
if (status?.succeeded && status.succeeded > 0) {
return; // Build completed
}
if (status?.failed && status.failed > 0) {
// Try to get pod logs for more info
const logs = await this.getBuildLogs(coreApi, jobName, namespace);
throw new Error(`Build job ${jobName} failed.\nLogs:\n${logs}`);
}
// Wait 5 seconds before polling again
await new Promise((resolve) => setTimeout(resolve, 5000));
}
throw new Error(`Build job ${jobName} timed out after ${timeoutSeconds}s`);
}
private async getBuildLogs(
coreApi: k8s.CoreV1Api,
jobName: string,
namespace: string,
): Promise<string> {
try {
const pods = await coreApi.listNamespacedPod(
namespace,
undefined,
undefined,
undefined,
undefined,
`job-name=${jobName}`,
);
if (pods.body.items.length === 0) {
return 'No pods found for build job.';
}
const podName = pods.body.items[0].metadata?.name;
if (!podName) return 'Pod name not found.';
// Get logs from all containers (init + kaniko)
let allLogs = '';
const containers = [
...(pods.body.items[0].spec?.initContainers || []),
...(pods.body.items[0].spec?.containers || []),
];
for (const container of containers) {
try {
const logResponse = await coreApi.readNamespacedPodLog(
podName,
namespace,
container.name,
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
500,
);
allLogs += `\n--- ${container.name} ---\n${logResponse.body}`;
} catch {
allLogs += `\n--- ${container.name} --- (no logs available)`;
}
}
return allLogs;
} catch (e: any) {
return `Failed to retrieve logs: ${e.message}`;
}
}
}