Detect and validate app runtime from uploaded archives.

Reject zip uploads when the selected runtime does not match archive contents, and re-validate before Kaniko builds to fail fast instead of producing the wrong Dockerfile.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-06-30 00:27:03 +03:30
parent 837f0fa63f
commit 8d1855b89c
21 changed files with 654 additions and 98 deletions
+38 -87
View File
@@ -11,6 +11,13 @@ import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
import {
detectDjangoSettingsModule,
detectGoBuildTarget,
detectShallowCsproj,
listArchiveEntries,
validateRuntimeFromArchive,
} from './runtime-detector';
const execFileAsync = promisify(execFile);
@@ -314,8 +321,16 @@ export class BuildService {
this.beginBuildSession(deploymentId);
}
const codePath = app.codePath ? path.resolve(app.codePath) : null;
const hasUploadedCode = codePath && fs.existsSync(codePath);
if (hasUploadedCode) {
await validateRuntimeFromArchive(app.runtime, codePath);
}
const archiveEntries = hasUploadedCode ? await listArchiveEntries(codePath!) : [];
// Determine Dockerfile based on runtime
const dockerfileContent = this.generateDockerfile(app);
const dockerfileContent = this.generateDockerfile(app, archiveEntries);
// Create Kaniko build pod
const buildPodName = `build-${app.name}-${tag}`.substring(0, 63).replace(/[^a-z0-9-]/g, '');
@@ -345,8 +360,6 @@ export class BuildService {
this.throwIfCancelled(deploymentId);
// 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
@@ -957,74 +970,8 @@ export class BuildService {
}
}
/**
* 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 {
const runtime = this.detectRuntime(app);
switch (runtime) {
private generateDockerfile(app: Application, archiveEntries: string[] = []): string {
switch (app.runtime) {
case AppRuntime.NODEJS:
return this.nodeDockerfile(app);
case AppRuntime.LARAVEL:
@@ -1032,17 +979,17 @@ export class BuildService {
case AppRuntime.WORDPRESS:
return this.wordpressDockerfile(app);
case AppRuntime.GO:
return this.goDockerfile(app);
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);
return this.djangoDockerfile(app, archiveEntries);
case AppRuntime.DOTNET:
return this.dotnetDockerfile(app);
return this.dotnetDockerfile(app, archiveEntries);
default:
throw new Error(`Unsupported runtime: ${runtime}`);
throw new Error(`Unsupported runtime: ${app.runtime}`);
}
}
@@ -1314,9 +1261,10 @@ CMD []`
}
// ─── Go Dockerfile ─────────────────────────────────────────────────
private goDockerfile(app: Application): string {
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 golang:${goVersion}-alpine AS builder
WORKDIR /app
@@ -1332,7 +1280,7 @@ RUN go mod download || true
COPY . .
# Build the application
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget}
# --- Production stage ---
FROM alpine:3.19
@@ -1487,9 +1435,10 @@ CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicor
}
// ─── Django Dockerfile ─────────────────────────────────────────────
private djangoDockerfile(app: Application): string {
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 python:${pythonVersion}-slim AS builder
WORKDIR /app
@@ -1530,7 +1479,7 @@ USER appuser
ENV PATH=/home/appuser/.local/bin:$PATH
ENV PORT=${port}
ENV PYTHONUNBUFFERED=1
ENV DJANGO_SETTINGS_MODULE=config.settings
ENV DJANGO_SETTINGS_MODULE=${settingsModule}
EXPOSE ${port}
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \\
@@ -1549,21 +1498,23 @@ CMD sh -c "\\
}
// ─── .NET Dockerfile ───────────────────────────────────────────────
private dotnetDockerfile(app: Application): string {
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 csproj and restore dependencies
COPY *.csproj ./
RUN dotnet restore || true
# Copy everything else and build
# Copy source and locate project file (supports nested csproj layouts)
COPY . .
RUN dotnet publish -c Release -o /app/publish --no-restore 2>/dev/null || \\
dotnet publish -c Release -o /app/publish
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}