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:
Generated
+31
-1
@@ -34,7 +34,8 @@
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"typeorm": "^1.0.0",
|
||||
"uuid": "^14.0.0"
|
||||
"uuid": "^14.0.0",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
@@ -47,6 +48,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/yauzl": "^3.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^9.0.0",
|
||||
@@ -3086,6 +3088,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/yauzl": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-3.4.0.tgz",
|
||||
"integrity": "sha512-NRPn5w6h8dhcnmx3YIRQcqMywY/+nND/uOkJessedcrowO3C0AssHp3tMJpxKAwOhFOo0OV1y9VtsC5hbKKBAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz",
|
||||
@@ -8694,6 +8706,12 @@
|
||||
"resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz",
|
||||
"integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg=="
|
||||
},
|
||||
"node_modules/pend": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
|
||||
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||
@@ -11239,6 +11257,18 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yauzl": {
|
||||
"version": "3.4.0",
|
||||
"resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
|
||||
"integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pend": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/yn": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
|
||||
|
||||
@@ -50,7 +50,8 @@
|
||||
"reflect-metadata": "^0.2.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"typeorm": "^1.0.0",
|
||||
"uuid": "^14.0.0"
|
||||
"uuid": "^14.0.0",
|
||||
"yauzl": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.23",
|
||||
@@ -63,6 +64,7 @@
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^24.0.0",
|
||||
"@types/passport-jwt": "^4.0.0",
|
||||
"@types/yauzl": "^3.4.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"eslint": "^9.0.0",
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes, ApiBadRequestResponse } from '@nestjs/swagger';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { DomainService } from './domain.service';
|
||||
@@ -70,6 +70,18 @@ export class ApplicationsController {
|
||||
@Post(':id/upload')
|
||||
@Throttle({ default: { limit: 10, ttl: 60_000 } })
|
||||
@ApiOperation({ summary: 'Upload application code (zip file)' })
|
||||
@ApiBadRequestResponse({
|
||||
description: 'Runtime mismatch between selected app type and archive contents',
|
||||
schema: {
|
||||
example: {
|
||||
statusCode: 400,
|
||||
message: 'Selected runtime "nodejs" does not match the uploaded source (detected "go").',
|
||||
configured: 'nodejs',
|
||||
detected: 'go',
|
||||
signals: ['go.mod'],
|
||||
},
|
||||
},
|
||||
})
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
limits: { fileSize: 10 * 1024 * 1024 * 1024 }, // 10 GiB max application archive
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
} from '../common/enums';
|
||||
import { ensureAppUrlEnv } from './app-url.util';
|
||||
import { normalizeCreateApplicationDto } from './managed-service.util';
|
||||
import {
|
||||
assertRuntimeMatch,
|
||||
detectRuntimeFromArchive,
|
||||
} from '../build/runtime-detector';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationsService {
|
||||
@@ -271,12 +275,28 @@ export class ApplicationsService {
|
||||
const zipPath = path.join(appDir, 'source.zip');
|
||||
fs.writeFileSync(zipPath, file.buffer);
|
||||
|
||||
// Update app with code path
|
||||
app.codePath = zipPath;
|
||||
const saved = await this.appsRepository.save(app);
|
||||
try {
|
||||
const detected = await detectRuntimeFromArchive(zipPath);
|
||||
assertRuntimeMatch(app.runtime, detected);
|
||||
|
||||
this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
return saved;
|
||||
app.codePath = zipPath;
|
||||
const saved = await this.appsRepository.save(app);
|
||||
|
||||
if (detected.confidence === 'low') {
|
||||
Object.assign(saved, {
|
||||
runtimeWarning:
|
||||
'Could not determine the project type from the archive with high confidence. Build may fail if the selected runtime is wrong.',
|
||||
});
|
||||
}
|
||||
|
||||
this.logger.log(`Uploaded code for ${app.name} → ${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
|
||||
return saved;
|
||||
} catch (err) {
|
||||
if (fs.existsSync(zipPath)) {
|
||||
fs.unlinkSync(zipPath);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async uploadDbDump(id: string, userId: string, file: Express.Multer.File): Promise<Application> {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as path from 'path';
|
||||
import { BuildService } from './build.service';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
@@ -53,6 +54,21 @@ describe('BuildService', () => {
|
||||
expect(dockerfile).toContain('EXPOSE 8080');
|
||||
});
|
||||
|
||||
it('generates Go Dockerfile with cmd package when present in archive entries', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.GO,
|
||||
runtimeVersion: '1.22',
|
||||
port: 8080,
|
||||
} as Application;
|
||||
|
||||
const dockerfile = (service as any).generateDockerfile(app, [
|
||||
'go.mod',
|
||||
'cmd/server/main.go',
|
||||
]) as string;
|
||||
|
||||
expect(dockerfile).toContain('go build -a -installsuffix cgo -ldflags="-w -s" -o main ./cmd/server');
|
||||
});
|
||||
|
||||
it('generates Node.js Dockerfile with default port', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.NODEJS,
|
||||
@@ -64,5 +80,52 @@ describe('BuildService', () => {
|
||||
expect(dockerfile).toContain('FROM node:20');
|
||||
expect(dockerfile).toContain('EXPOSE 3000');
|
||||
});
|
||||
|
||||
it('generates Laravel Dockerfile with artisan migrate', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.LARAVEL,
|
||||
phpVersion: '8.3',
|
||||
} as Application;
|
||||
|
||||
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||
|
||||
expect(dockerfile).toContain('php:8.3');
|
||||
expect(dockerfile).toContain('artisan migrate');
|
||||
});
|
||||
|
||||
it('generates WordPress Dockerfile with official image', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.WORDPRESS,
|
||||
runtimeVersion: '6.4',
|
||||
} as Application;
|
||||
|
||||
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||
|
||||
expect(dockerfile).toContain('wordpress:6.4');
|
||||
});
|
||||
|
||||
it('generates Django Dockerfile with detected settings module', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.DJANGO,
|
||||
runtimeVersion: '3.12',
|
||||
} as Application;
|
||||
|
||||
const dockerfile = (service as any).generateDockerfile(app, ['myproject/settings.py']) as string;
|
||||
|
||||
expect(dockerfile).toContain('DJANGO_SETTINGS_MODULE=myproject.settings');
|
||||
expect(dockerfile).toContain('gunicorn');
|
||||
});
|
||||
|
||||
it('generates .NET Dockerfile that restores nested csproj', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.DOTNET,
|
||||
runtimeVersion: '8.0',
|
||||
} as Application;
|
||||
|
||||
const dockerfile = (service as any).generateDockerfile(app, ['src/App/App.csproj']) as string;
|
||||
|
||||
expect(dockerfile).toContain('CSPROJ="src/App/App.csproj"');
|
||||
expect(dockerfile).toContain('dotnet publish "$CSPROJ"');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,141 @@
|
||||
import * as path from 'path';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
import {
|
||||
assertRuntimeMatch,
|
||||
detectDjangoSettingsModule,
|
||||
detectGoBuildTarget,
|
||||
detectRuntimeFromArchive,
|
||||
detectRuntimeFromEntries,
|
||||
listArchiveEntries,
|
||||
normalizeEntryPath,
|
||||
stripCommonRootPrefix,
|
||||
} from './runtime-detector';
|
||||
|
||||
const fixturesDir = path.join(__dirname, 'fixtures');
|
||||
|
||||
describe('runtime-detector', () => {
|
||||
describe('normalizeEntryPath / stripCommonRootPrefix', () => {
|
||||
it('strips a single root folder prefix', () => {
|
||||
const entries = ['myapp/package.json', 'myapp/src/index.js'];
|
||||
expect(stripCommonRootPrefix(entries)).toEqual(['package.json', 'src/index.js']);
|
||||
});
|
||||
|
||||
it('normalizes leading ./ segments', () => {
|
||||
expect(normalizeEntryPath('./package.json')).toBe('package.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectRuntimeFromEntries', () => {
|
||||
it('detects nodejs in nested layout', () => {
|
||||
const result = detectRuntimeFromEntries(['myapp/package.json']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.NODEJS, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects go from go.mod', () => {
|
||||
const result = detectRuntimeFromEntries(['go.mod', 'main.go']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.GO, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects laravel from artisan + composer.json', () => {
|
||||
const result = detectRuntimeFromEntries(['artisan', 'composer.json']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.LARAVEL, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects php from composer.json without artisan', () => {
|
||||
const result = detectRuntimeFromEntries(['composer.json', 'index.php']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.PHP, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects django from manage.py', () => {
|
||||
const result = detectRuntimeFromEntries(['manage.py', 'requirements.txt']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.DJANGO, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects python from requirements.txt', () => {
|
||||
const result = detectRuntimeFromEntries(['requirements.txt', 'app.py']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.PYTHON, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects dotnet from shallow csproj', () => {
|
||||
const result = detectRuntimeFromEntries(['src/App/App.csproj']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.DOTNET, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('detects wordpress wp-content migrate layout', () => {
|
||||
const result = detectRuntimeFromEntries(['themes/twenty/style.css', 'plugins/hello/hello.php']);
|
||||
expect(result).toMatchObject({ runtime: AppRuntime.WORDPRESS, confidence: 'high' });
|
||||
});
|
||||
|
||||
it('returns low confidence when package.json and composer.json coexist', () => {
|
||||
const result = detectRuntimeFromEntries(['package.json', 'composer.json']);
|
||||
expect(result).toMatchObject({ runtime: null, confidence: 'low' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assertRuntimeMatch', () => {
|
||||
it('passes when configured runtime matches detection', () => {
|
||||
expect(() =>
|
||||
assertRuntimeMatch(AppRuntime.GO, {
|
||||
runtime: AppRuntime.GO,
|
||||
confidence: 'high',
|
||||
signals: ['go.mod'],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('throws BadRequestException on high-confidence mismatch', () => {
|
||||
expect(() =>
|
||||
assertRuntimeMatch(AppRuntime.NODEJS, {
|
||||
runtime: AppRuntime.GO,
|
||||
confidence: 'high',
|
||||
signals: ['go.mod'],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('allows upload when confidence is low', () => {
|
||||
expect(() =>
|
||||
assertRuntimeMatch(AppRuntime.NODEJS, {
|
||||
runtime: null,
|
||||
confidence: 'low',
|
||||
signals: [],
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('listArchiveEntries + detectRuntimeFromArchive', () => {
|
||||
it.each([
|
||||
['nodejs-nested.zip', AppRuntime.NODEJS],
|
||||
['go-mod.zip', AppRuntime.GO],
|
||||
['laravel.zip', AppRuntime.LARAVEL],
|
||||
['php-composer.zip', AppRuntime.PHP],
|
||||
['django.zip', AppRuntime.DJANGO],
|
||||
['wordpress-wp-content.zip', AppRuntime.WORDPRESS],
|
||||
] as const)('reads %s as %s', async (fixture, runtime) => {
|
||||
const zipPath = path.join(fixturesDir, fixture);
|
||||
const entries = await listArchiveEntries(zipPath);
|
||||
expect(entries.length).toBeGreaterThan(0);
|
||||
const detected = await detectRuntimeFromArchive(zipPath);
|
||||
expect(detected.runtime).toBe(runtime);
|
||||
expect(detected.confidence).toBe('high');
|
||||
});
|
||||
|
||||
it('detects go inside a single nested root folder', async () => {
|
||||
const zipPath = path.join(fixturesDir, 'go-nested-root.zip');
|
||||
const detected = await detectRuntimeFromArchive(zipPath);
|
||||
expect(detected.runtime).toBe(AppRuntime.GO);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dockerfile helpers', () => {
|
||||
it('prefers cmd/*/main.go for go build target', () => {
|
||||
expect(detectGoBuildTarget(['go.mod', 'cmd/server/main.go'])).toBe('./cmd/server');
|
||||
});
|
||||
|
||||
it('infers django settings module from project layout', () => {
|
||||
expect(detectDjangoSettingsModule(['myproject/settings.py'])).toBe('myproject.settings');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,288 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { execFile } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { promisify } from 'util';
|
||||
import * as yauzl from 'yauzl';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface RuntimeDetectionResult {
|
||||
runtime: AppRuntime | null;
|
||||
confidence: 'high' | 'low';
|
||||
signals: string[];
|
||||
}
|
||||
|
||||
export function normalizeEntryPath(entry: string): string {
|
||||
return entry.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
/** Strip a single top-level folder when every entry lives under it. */
|
||||
export function stripCommonRootPrefix(entries: string[]): string[] {
|
||||
const normalized = entries.map(normalizeEntryPath).filter(Boolean);
|
||||
if (normalized.length === 0) return [];
|
||||
|
||||
const firstSegments = new Set<string>();
|
||||
for (const entry of normalized) {
|
||||
const seg = entry.split('/')[0];
|
||||
if (seg) firstSegments.add(seg);
|
||||
}
|
||||
if (firstSegments.size !== 1) return normalized;
|
||||
|
||||
const root = [...firstSegments][0];
|
||||
const allUnderRoot = normalized.every((entry) => entry === root || entry.startsWith(`${root}/`));
|
||||
if (!allUnderRoot) return normalized;
|
||||
|
||||
const nestedUnderRoot = normalized.filter((entry) => entry.startsWith(`${root}/`));
|
||||
if (nestedUnderRoot.length === 0) return normalized;
|
||||
|
||||
if (normalized.length === 1) {
|
||||
const parts = normalized[0].split('/').filter(Boolean);
|
||||
if (parts.length === 2) {
|
||||
return [parts[1]];
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
return normalized.map((entry) => {
|
||||
if (entry === root) return entry;
|
||||
return entry.slice(root.length + 1);
|
||||
});
|
||||
}
|
||||
|
||||
function hasBasename(entries: string[], name: string): boolean {
|
||||
return entries.some((entry) => path.posix.basename(entry) === name);
|
||||
}
|
||||
|
||||
function hasDirectory(entries: string[], dirName: string): boolean {
|
||||
return entries.some((entry) => {
|
||||
const parts = entry.split('/').filter(Boolean);
|
||||
return parts.includes(dirName);
|
||||
});
|
||||
}
|
||||
|
||||
function hasShallowCsproj(entries: string[]): string | null {
|
||||
for (const entry of entries) {
|
||||
if (!entry.endsWith('.csproj')) continue;
|
||||
const depth = entry.split('/').filter(Boolean).length;
|
||||
if (depth <= 3) return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function detectRuntimeFromEntries(rawEntries: string[]): RuntimeDetectionResult {
|
||||
const entries = stripCommonRootPrefix(rawEntries);
|
||||
const signals: string[] = [];
|
||||
|
||||
const note = (signal: string) => {
|
||||
if (!signals.includes(signal)) signals.push(signal);
|
||||
};
|
||||
|
||||
const hasWpAdmin = hasDirectory(entries, 'wp-admin');
|
||||
const hasWpContent = hasDirectory(entries, 'wp-content');
|
||||
const hasWpConfig =
|
||||
hasBasename(entries, 'wp-config.php') ||
|
||||
hasBasename(entries, 'wp-config-sample.php') ||
|
||||
entries.some((e) => /wp-config[^/]*\.php$/i.test(e));
|
||||
const hasThemes = hasDirectory(entries, 'themes');
|
||||
const hasPlugins = hasDirectory(entries, 'plugins');
|
||||
|
||||
if (hasWpAdmin || (hasWpContent && hasWpConfig) || (hasThemes && hasPlugins && !hasWpAdmin)) {
|
||||
if (hasWpAdmin) note('wp-admin');
|
||||
if (hasWpContent) note('wp-content');
|
||||
if (hasWpConfig) note('wp-config.php');
|
||||
if (hasThemes) note('themes');
|
||||
if (hasPlugins) note('plugins');
|
||||
return { runtime: AppRuntime.WORDPRESS, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
const hasArtisan = hasBasename(entries, 'artisan');
|
||||
const hasComposer = hasBasename(entries, 'composer.json');
|
||||
if (hasArtisan && hasComposer) {
|
||||
note('artisan');
|
||||
note('composer.json');
|
||||
return { runtime: AppRuntime.LARAVEL, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
const hasManagePy = hasBasename(entries, 'manage.py');
|
||||
if (hasManagePy) {
|
||||
note('manage.py');
|
||||
if (hasBasename(entries, 'requirements.txt')) note('requirements.txt');
|
||||
return { runtime: AppRuntime.DJANGO, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
if (hasBasename(entries, 'go.mod')) {
|
||||
note('go.mod');
|
||||
return { runtime: AppRuntime.GO, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
const csproj = hasShallowCsproj(entries);
|
||||
if (csproj) {
|
||||
note(csproj);
|
||||
return { runtime: AppRuntime.DOTNET, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
const hasPackageJson = hasBasename(entries, 'package.json');
|
||||
if (hasPackageJson && hasComposer) {
|
||||
note('package.json');
|
||||
note('composer.json');
|
||||
return { runtime: null, confidence: 'low', signals };
|
||||
}
|
||||
|
||||
if (hasComposer) {
|
||||
note('composer.json');
|
||||
return { runtime: AppRuntime.PHP, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
const hasRequirements = hasBasename(entries, 'requirements.txt');
|
||||
const hasPyproject = hasBasename(entries, 'pyproject.toml');
|
||||
if (hasRequirements || hasPyproject) {
|
||||
if (hasRequirements) note('requirements.txt');
|
||||
if (hasPyproject) note('pyproject.toml');
|
||||
return { runtime: AppRuntime.PYTHON, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
if (hasPackageJson) {
|
||||
note('package.json');
|
||||
return { runtime: AppRuntime.NODEJS, confidence: 'high', signals };
|
||||
}
|
||||
|
||||
return { runtime: null, confidence: 'low', signals };
|
||||
}
|
||||
|
||||
export function assertRuntimeMatch(configured: AppRuntime, detected: RuntimeDetectionResult): void {
|
||||
if (detected.confidence !== 'high' || detected.runtime === null) {
|
||||
return;
|
||||
}
|
||||
if (detected.runtime === configured) {
|
||||
return;
|
||||
}
|
||||
throw new BadRequestException({
|
||||
message: `Selected runtime "${configured}" does not match the uploaded source (detected "${detected.runtime}").`,
|
||||
configured,
|
||||
detected: detected.runtime,
|
||||
signals: detected.signals,
|
||||
});
|
||||
}
|
||||
|
||||
function listZipEntries(archivePath: string): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
yauzl.open(archivePath, { lazyEntries: true }, (err, zipfile) => {
|
||||
if (err || !zipfile) {
|
||||
reject(err ?? new Error(`Failed to open zip: ${archivePath}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const entries: string[] = [];
|
||||
zipfile.readEntry();
|
||||
zipfile.on('entry', (entry) => {
|
||||
entries.push(entry.fileName);
|
||||
zipfile.readEntry();
|
||||
});
|
||||
zipfile.on('end', () => resolve(entries));
|
||||
zipfile.on('error', reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function listTarEntries(archivePath: string): Promise<string[]> {
|
||||
const { stdout } = await execFileAsync('tar', ['-tf', archivePath]);
|
||||
return stdout.split('\n').filter(Boolean);
|
||||
}
|
||||
|
||||
/** List relative paths up to depth 2 inside a directory (for extracted sources). */
|
||||
export function listDirectoryEntriesShallow(dirPath: string, maxDepth = 2): string[] {
|
||||
const results: string[] = [];
|
||||
|
||||
const walk = (current: string, depth: number) => {
|
||||
if (depth > maxDepth) return;
|
||||
let names: string[];
|
||||
try {
|
||||
names = fs.readdirSync(current);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const name of names) {
|
||||
const full = path.join(current, name);
|
||||
const rel = path.relative(dirPath, full).replace(/\\/g, '/');
|
||||
results.push(rel);
|
||||
let stat: fs.Stats;
|
||||
try {
|
||||
stat = fs.statSync(full);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walk(full, depth + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(dirPath, 0);
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function listArchiveEntries(archivePath: string): Promise<string[]> {
|
||||
const resolved = path.resolve(archivePath);
|
||||
if (!fs.existsSync(resolved)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stat = fs.statSync(resolved);
|
||||
if (stat.isDirectory()) {
|
||||
return listDirectoryEntriesShallow(resolved);
|
||||
}
|
||||
|
||||
const lower = resolved.toLowerCase();
|
||||
if (lower.endsWith('.zip')) {
|
||||
return listZipEntries(resolved);
|
||||
}
|
||||
if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz') || lower.endsWith('.tar')) {
|
||||
return listTarEntries(resolved);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function detectRuntimeFromArchive(archivePath: string): Promise<RuntimeDetectionResult> {
|
||||
const entries = await listArchiveEntries(archivePath);
|
||||
return detectRuntimeFromEntries(entries);
|
||||
}
|
||||
|
||||
export async function validateRuntimeFromArchive(
|
||||
configured: AppRuntime,
|
||||
archivePath: string | null | undefined,
|
||||
): Promise<RuntimeDetectionResult> {
|
||||
if (!archivePath) {
|
||||
return { runtime: null, confidence: 'low', signals: [] };
|
||||
}
|
||||
const detected = await detectRuntimeFromArchive(archivePath);
|
||||
assertRuntimeMatch(configured, detected);
|
||||
return detected;
|
||||
}
|
||||
|
||||
/** Prefer `cmd/<name>/main.go` when present. */
|
||||
export function detectGoBuildTarget(entries: string[]): string {
|
||||
const normalized = stripCommonRootPrefix(entries);
|
||||
const cmdMain = normalized.find((entry) => /^cmd\/[^/]+\/main\.go$/.test(entry));
|
||||
if (cmdMain) {
|
||||
return `./${cmdMain.replace(/\/main\.go$/, '')}`;
|
||||
}
|
||||
return '.';
|
||||
}
|
||||
|
||||
export function detectShallowCsproj(entries: string[]): string | null {
|
||||
return hasShallowCsproj(stripCommonRootPrefix(entries));
|
||||
}
|
||||
|
||||
/** Infer DJANGO_SETTINGS_MODULE from settings.py layout. */
|
||||
export function detectDjangoSettingsModule(entries: string[]): string {
|
||||
for (const entry of entries.map(normalizeEntryPath).filter(Boolean)) {
|
||||
const parts = entry.split('/').filter(Boolean);
|
||||
const settingsIdx = parts.findIndex((part) => part === 'settings.py');
|
||||
if (settingsIdx === 1) return `${parts[0]}.settings`;
|
||||
if (settingsIdx === 2) return `${parts[0]}.${parts[1]}.settings`;
|
||||
}
|
||||
return 'config.settings';
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { useAuthStore } from '@/lib/store';
|
||||
import { useDeployProgressStore } from '@/lib/deploy-progress-store';
|
||||
import { useDeployProgressActions } from '@/lib/use-deploy-progress-actions';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { readRuntimeMismatch } from '@/lib/errors';
|
||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||
import type {
|
||||
CreateApplicationDto,
|
||||
DeployCostPreview,
|
||||
@@ -23,6 +25,27 @@ import type {
|
||||
} from '@/types';
|
||||
import { Hexagon, FolderUp, Link as LinkIcon, CheckCircle, Package, Server, Scale, Home, Target, BarChart3, RotateCw, KeyRound, Rocket, Clock, Upload, XCircle, Database, Eye, EyeOff, RefreshCw, DollarSign, Wallet, CreditCard, Loader2, Globe, Copy, AlertCircle, ShieldCheck } from 'lucide-react';
|
||||
|
||||
const RUNTIME_LABELS: Record<string, string> = {
|
||||
nodejs: 'Node.js',
|
||||
laravel: 'Laravel',
|
||||
wordpress: 'WordPress',
|
||||
go: 'Go',
|
||||
php: 'PHP',
|
||||
python: 'Python',
|
||||
django: 'Django',
|
||||
dotnet: '.NET',
|
||||
};
|
||||
|
||||
function resolveDeployErrorFallback(err: unknown, fallback: string, dict: Dictionary): string {
|
||||
const mismatch = readRuntimeMismatch(err);
|
||||
if (!mismatch) return fallback;
|
||||
const configured = RUNTIME_LABELS[mismatch.configured] ?? mismatch.configured;
|
||||
const detected = RUNTIME_LABELS[mismatch.detected] ?? mismatch.detected;
|
||||
return dict.errors.runtimeMismatch
|
||||
.replace('{configured}', configured)
|
||||
.replace('{detected}', detected);
|
||||
}
|
||||
|
||||
/** WordPress uses the managed image stack — wizard hides env & optional services; strip if ever sent. */
|
||||
function sanitizePayloadForWordPressRuntime(payload: CreateApplicationDto): CreateApplicationDto {
|
||||
if (payload.runtime !== 'wordpress') return payload;
|
||||
@@ -479,7 +502,7 @@ export default function DeployPage() {
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setDeployStage('error');
|
||||
notify.error(err, 'Payment or deployment failed');
|
||||
notify.error(err, resolveDeployErrorFallback(err, 'Payment or deployment failed', t));
|
||||
setUploadProgress(0);
|
||||
setDbUploadProgress(0);
|
||||
setTimeout(() => setDeployStage('idle'), 2000);
|
||||
@@ -561,7 +584,7 @@ export default function DeployPage() {
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setDeployStage('error');
|
||||
notify.error(err, 'Payment failed');
|
||||
notify.error(err, resolveDeployErrorFallback(err, 'Payment failed', t));
|
||||
setUploadProgress(0);
|
||||
setDbUploadProgress(0);
|
||||
setTimeout(() => setDeployStage('idle'), 2000);
|
||||
@@ -616,7 +639,7 @@ export default function DeployPage() {
|
||||
},
|
||||
onError: (err: any) => {
|
||||
setDeployStage('error');
|
||||
notify.error(err, 'Failed to create application');
|
||||
notify.error(err, resolveDeployErrorFallback(err, 'Failed to create application', t));
|
||||
setUploadProgress(0);
|
||||
setDbUploadProgress(0);
|
||||
setTimeout(() => setDeployStage('idle'), 2000);
|
||||
|
||||
@@ -37,6 +37,7 @@ const en: Dictionary = {
|
||||
validation: 'The information you entered is invalid. Please check your input.',
|
||||
rateLimit: 'Too many requests. Please wait a moment and try again.',
|
||||
server: 'A server error occurred. Please try again shortly.',
|
||||
runtimeMismatch: 'The selected project type ({configured}) does not match the uploaded archive ({detected}).',
|
||||
},
|
||||
|
||||
language: {
|
||||
|
||||
@@ -36,6 +36,7 @@ const fa = {
|
||||
validation: 'اطلاعات واردشده درست نیست. لطفاً ورودیها را بررسی کنید.',
|
||||
rateLimit: 'تعداد درخواستها زیاد است. کمی صبر کنید و دوباره تلاش کنید.',
|
||||
server: 'خطایی در سرور رخ داد. لطفاً کمی بعد دوباره تلاش کنید.',
|
||||
runtimeMismatch: 'نوع پروژه انتخابشده ({configured}) با محتوای فایل ({detected}) همخوان نیست.',
|
||||
},
|
||||
|
||||
language: {
|
||||
|
||||
@@ -22,6 +22,30 @@ export interface ClassifiedError {
|
||||
backendMessage?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeMismatchPayload {
|
||||
configured: string;
|
||||
detected: string;
|
||||
signals?: string[];
|
||||
}
|
||||
|
||||
/** Reads structured runtime mismatch fields from a 400 upload response. */
|
||||
export function readRuntimeMismatch(err: unknown): RuntimeMismatchPayload | null {
|
||||
if (!axios.isAxiosError(err) || err.response?.status !== 400) return null;
|
||||
const data = err.response.data;
|
||||
if (!data || typeof data !== 'object') return null;
|
||||
const body = data as Record<string, unknown>;
|
||||
if (typeof body.configured === 'string' && typeof body.detected === 'string') {
|
||||
return {
|
||||
configured: body.configured,
|
||||
detected: body.detected,
|
||||
signals: Array.isArray(body.signals)
|
||||
? body.signals.filter((s): s is string => typeof s === 'string')
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Flattens NestJS-style `message: string | string[]` into one string. */
|
||||
function readBackendMessage(data: unknown): string | undefined {
|
||||
if (!data || typeof data !== 'object') return undefined;
|
||||
|
||||
Reference in New Issue
Block a user