From 8d1855b89c8b13378df47eb239b187598f044f36 Mon Sep 17 00:00:00 2001 From: keyhan Date: Tue, 30 Jun 2026 00:27:03 +0330 Subject: [PATCH] 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 --- backend/package-lock.json | 32 +- backend/package.json | 4 +- .../applications/applications.controller.ts | 14 +- .../src/applications/applications.service.ts | 30 +- backend/src/build/build.service.spec.ts | 63 ++++ backend/src/build/build.service.ts | 125 +++----- backend/src/build/fixtures/.gitkeep | 0 backend/src/build/fixtures/django.zip | Bin 0 -> 355 bytes backend/src/build/fixtures/go-mod.zip | Bin 0 -> 659 bytes backend/src/build/fixtures/go-nested-root.zip | Bin 0 -> 877 bytes backend/src/build/fixtures/laravel.zip | Bin 0 -> 338 bytes .../build/fixtures/mismatch-node-in-go.zip | Bin 0 -> 324 bytes backend/src/build/fixtures/nodejs-nested.zip | Bin 0 -> 340 bytes backend/src/build/fixtures/php-composer.zip | Bin 0 -> 178 bytes .../build/fixtures/wordpress-wp-content.zip | Bin 0 -> 984 bytes backend/src/build/runtime-detector.spec.ts | 141 +++++++++ backend/src/build/runtime-detector.ts | 288 ++++++++++++++++++ .../src/app/[lang]/dashboard/deploy/page.tsx | 29 +- frontend/src/i18n/dictionaries/en.ts | 1 + frontend/src/i18n/dictionaries/fa.ts | 1 + frontend/src/lib/errors.ts | 24 ++ 21 files changed, 654 insertions(+), 98 deletions(-) create mode 100644 backend/src/build/fixtures/.gitkeep create mode 100644 backend/src/build/fixtures/django.zip create mode 100644 backend/src/build/fixtures/go-mod.zip create mode 100644 backend/src/build/fixtures/go-nested-root.zip create mode 100644 backend/src/build/fixtures/laravel.zip create mode 100644 backend/src/build/fixtures/mismatch-node-in-go.zip create mode 100644 backend/src/build/fixtures/nodejs-nested.zip create mode 100644 backend/src/build/fixtures/php-composer.zip create mode 100644 backend/src/build/fixtures/wordpress-wp-content.zip create mode 100644 backend/src/build/runtime-detector.spec.ts create mode 100644 backend/src/build/runtime-detector.ts diff --git a/backend/package-lock.json b/backend/package-lock.json index 76be2cb..baf34f8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index 56f9b69..31846f1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 55e4914..67c35bc 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -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 diff --git a/backend/src/applications/applications.service.ts b/backend/src/applications/applications.service.ts index 6dcb145..5cd2381 100644 --- a/backend/src/applications/applications.service.ts +++ b/backend/src/applications/applications.service.ts @@ -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 { diff --git a/backend/src/build/build.service.spec.ts b/backend/src/build/build.service.spec.ts index fe3aa51..8701a66 100644 --- a/backend/src/build/build.service.spec.ts +++ b/backend/src/build/build.service.spec.ts @@ -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"'); + }); }); }); diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index 1851e7b..f99e3bc 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -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///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} diff --git a/backend/src/build/fixtures/.gitkeep b/backend/src/build/fixtures/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/build/fixtures/django.zip b/backend/src/build/fixtures/django.zip new file mode 100644 index 0000000000000000000000000000000000000000..ab9f101061b4bdbc452eaa68fb510504f1782fa6 GIT binary patch literal 355 zcmWIWW@h1H0D-AY_hO!vxF2T&vO!pYL586ywXig^C^a`VucTP7q@pA=gp+}J(JiMe z5H79YW?*Fb%E-XLA_7#Ol9ia3o*#f>-ogn!T%tfZ5atA$mz$WEn4YRvP>IhVWkvnc z;v)T|%sl#LPUs^n82{()RE<=jY=edcyb2 zneLO`+GoAlj7?2eXEUO@G=xQYsu<7$5M~2flAf=Zo1cQuV4!qqPO3s`MPhD2PO4sV zey)CEK|z2wBa<96uJDrpnhpX23~wDlG$b@xA)$#8lDJLcW(Nn|Qx*u9R&X;gvV3J^ zU|02J$l?8|aE^ASCigH&htK z&^}BE!Rg~cN-V_DgN?qOgB N!fil}3qfvV002LAyTJee literal 0 HcmV?d00001 diff --git a/backend/src/build/fixtures/laravel.zip b/backend/src/build/fixtures/laravel.zip new file mode 100644 index 0000000000000000000000000000000000000000..9c5fbff99ce048ad26b273cd3101f148318c7af9 GIT binary patch literal 338 zcmWIWW@h1H0D-AY_hPa`^S%iI*&xi$Aj6PYRFYYom=_wt$-unmmQxl8msW5yFtU7Q zWME(s0V-2g)GsYA(of3F(@)JSQz*zN2tYB?dH=G-Oh9!Y%nLL!IX|}`zc{r>FRM5o zuff%|0p5&Ea?H3~Cjm5&fdS|Yh9!+47Tm3@5VxYa6WuUWcOe@V3^WYQmAH&#WdqsF N1cVEK^c)a}0RU)wN9zCp literal 0 HcmV?d00001 diff --git a/backend/src/build/fixtures/mismatch-node-in-go.zip b/backend/src/build/fixtures/mismatch-node-in-go.zip new file mode 100644 index 0000000000000000000000000000000000000000..54457224cf5fd39fb3dcd2456b3bc6fa3fe76a24 GIT binary patch literal 324 zcmWIWW@h1H0D-AY_hJ-YTxR6}vO$=QL53kcUoSU5B{YPSfqBs_rz{XIt>9*0WckX- zz`!B`R0WhS%}G_L2tYB%dH=G-Oh8!><^h^hkeHmEn4YSaRh*xP&$Q~=0B=SnIc8ig zk^q{=zyP$HVM!y11#=%O#C>RPLpKZ6Rmf&}0L?;mBQ6tJ*+BL(0pUC#JpsgF0076K BL6ZOg literal 0 HcmV?d00001 diff --git a/backend/src/build/fixtures/nodejs-nested.zip b/backend/src/build/fixtures/nodejs-nested.zip new file mode 100644 index 0000000000000000000000000000000000000000..e11c9f2a797ac577bfd566febd768dfe1aa8d526 GIT binary patch literal 340 zcmWIWW@h1H0D-AY_hP^dD8a@c!;o8BiOJcC>8W~I#rb*o%&Jz(OUzAGvQh$Cs#F``&B!FjjLU5j zP*(~tymbUIVJ>BbxD>;csAi$M6Ji$7M+{3E9pGl6x*C_ctZYCH3`{_{1W3;UaToxh CQbI2P literal 0 HcmV?d00001 diff --git a/backend/src/build/fixtures/php-composer.zip b/backend/src/build/fixtures/php-composer.zip new file mode 100644 index 0000000000000000000000000000000000000000..45abd27e006bd31f68e25290114c1327527afaae GIT binary patch literal 178 zcmWIWW@h1H0D-AY_hOv)FI&t6WP>m-gA7A*er`d2acYrXR&jn_Xb2|*^P*c$Ss+|m z!Og(P@|BT+fkgzUxVkpLn~_P58JAHKKs5{uK-COO8bK^HgIOU4qZu0D&B_K+%m{?R KK-v?;VE_RB^&|QK literal 0 HcmV?d00001 diff --git a/backend/src/build/fixtures/wordpress-wp-content.zip b/backend/src/build/fixtures/wordpress-wp-content.zip new file mode 100644 index 0000000000000000000000000000000000000000..1662fdfc8321bf8d15c5af585b78584c1686e7ee GIT binary patch literal 984 zcmWIWW@h1H0D-AY_hP^dD8a!X!%&b@nx2_gtREV}$-unmmQxl8msW5yFtU7QWME(s z0jdqaX#yXT2^pz5Ir$`*vu;!NVpgE(AS{ky4%jrkf{X%u4zsZbD!}bJcA%*x8L7Fc zlz0znKuLLOUP&bh-jlk$>n}IZYak5r9;!LTC6zg;ddbDb_`IjDr2y8Yprs$+&B!Fj zj4K)?fOdd@0K;2H5RHgbR!F2`L?~|4_!y8)b3!r=7Qw`si5k5SGl2<;VM${bhM7o_ zO`PFC6X1Xy#la7N;_x^p-7r+&D*_#i8gHQ?#F>d2`^dh}#4r;nE#NYol?~)wHX!^9 K)P58cjtl_n&B>Pl literal 0 HcmV?d00001 diff --git a/backend/src/build/runtime-detector.spec.ts b/backend/src/build/runtime-detector.spec.ts new file mode 100644 index 0000000..9aa26f2 --- /dev/null +++ b/backend/src/build/runtime-detector.spec.ts @@ -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'); + }); + }); +}); diff --git a/backend/src/build/runtime-detector.ts b/backend/src/build/runtime-detector.ts new file mode 100644 index 0000000..41c8dd1 --- /dev/null +++ b/backend/src/build/runtime-detector.ts @@ -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(); + 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 { + 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 { + 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 { + 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 { + const entries = await listArchiveEntries(archivePath); + return detectRuntimeFromEntries(entries); +} + +export async function validateRuntimeFromArchive( + configured: AppRuntime, + archivePath: string | null | undefined, +): Promise { + if (!archivePath) { + return { runtime: null, confidence: 'low', signals: [] }; + } + const detected = await detectRuntimeFromArchive(archivePath); + assertRuntimeMatch(configured, detected); + return detected; +} + +/** Prefer `cmd//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'; +} diff --git a/frontend/src/app/[lang]/dashboard/deploy/page.tsx b/frontend/src/app/[lang]/dashboard/deploy/page.tsx index 95294b6..283a52d 100644 --- a/frontend/src/app/[lang]/dashboard/deploy/page.tsx +++ b/frontend/src/app/[lang]/dashboard/deploy/page.tsx @@ -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 = { + 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); diff --git a/frontend/src/i18n/dictionaries/en.ts b/frontend/src/i18n/dictionaries/en.ts index 80f71d1..fa4f8c6 100644 --- a/frontend/src/i18n/dictionaries/en.ts +++ b/frontend/src/i18n/dictionaries/en.ts @@ -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: { diff --git a/frontend/src/i18n/dictionaries/fa.ts b/frontend/src/i18n/dictionaries/fa.ts index 5587c57..aee009b 100644 --- a/frontend/src/i18n/dictionaries/fa.ts +++ b/frontend/src/i18n/dictionaries/fa.ts @@ -36,6 +36,7 @@ const fa = { validation: 'اطلاعات واردشده درست نیست. لطفاً ورودی‌ها را بررسی کنید.', rateLimit: 'تعداد درخواست‌ها زیاد است. کمی صبر کنید و دوباره تلاش کنید.', server: 'خطایی در سرور رخ داد. لطفاً کمی بعد دوباره تلاش کنید.', + runtimeMismatch: 'نوع پروژه انتخاب‌شده ({configured}) با محتوای فایل ({detected}) هم‌خوان نیست.', }, language: { diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index 2e65759..3fa8ab9 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -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; + 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;