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