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