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
+141
View File
@@ -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');
});
});
});