3eff38f8d2
Rework the application build/deploy pipeline for scalability, reproducibility, and security: - Build queue: deploys run through a bounded-concurrency Bull queue (BUILD_CONCURRENCY, default 3) so concurrent user deploys can't flood the cluster with Kaniko jobs. Build state (progress / cancel / session) moves from in-memory Maps to Redis, so cancel + live logs work across backend replicas. - Nixpacks + BYO Dockerfile: code runtimes build via Nixpacks (or the user's own Dockerfile when present); the hand-written per-runtime Dockerfile generators and runtime auto-detection are removed. WordPress keeps its templated path. Build-time mirror env (NIXPACKS_BUILD_ENV) supports the Iran network. - Source upload to MinIO: archives stream to in-cluster MinIO; build pods pull via a presigned URL. Removes the PVC + helper pod + kubectl cp upload path. - Report-only Trivy scan after build; per-severity summary stored on the deployment and shown as a badge in the dashboard. Never gates a deploy. - Registry GC: a Redis-locked daily job keeps the newest N image tags per app (REGISTRY_KEEP_VERSIONS, default 3) and reclaims disk via garbage-collect. - Hardening: git tokens are delivered via a per-build Secret + git credential store instead of being embedded in the clone URL / Job manifest; build timeout is configurable. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
260 lines
9.4 KiB
TypeScript
260 lines
9.4 KiB
TypeScript
import { AppRuntime } from '../common/enums';
|
|
|
|
/**
|
|
* Tests for build service:
|
|
* • Nixpacks build preparation (BYO Dockerfile vs generated) for code runtimes
|
|
* • WordPress templated Dockerfile + helper-pod / entrypoint / zip-structure logic
|
|
*
|
|
* NOTE: like the rest of this file, the Nixpacks tests reproduce the pure logic
|
|
* locally instead of importing BuildService — the service pulls in the ESM
|
|
* `@kubernetes/client-node`, which this project's Jest config does not transform.
|
|
* Keep these copies in sync with nixpacksPrepareInitContainer in build.service.ts.
|
|
*/
|
|
|
|
/**
|
|
* Tests for the WordPress build flow — specifically:
|
|
* 1. Helper pod PVC race condition (must wait for termination)
|
|
* 2. WordPress Dockerfile generation correctness
|
|
* 3. Entrypoint should use ENTRYPOINT not CMD to avoid double docker-entrypoint.sh execution
|
|
*/
|
|
|
|
describe('WordPress Dockerfile generation', () => {
|
|
// Reproduce the wordpressDockerfile logic from build.service.ts
|
|
function wordpressDockerfile(app: {
|
|
runtimeVersion?: string;
|
|
phpVersion?: string;
|
|
codePath?: string;
|
|
port?: number;
|
|
}): string {
|
|
const wpVersion = app.runtimeVersion || '6.7';
|
|
const phpVersion = app.phpVersion || '8.3';
|
|
const hasUploadedCode = !!app.codePath;
|
|
|
|
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
|
|
RUN docker-php-ext-install opcache
|
|
RUN a2enmod rewrite
|
|
RUN echo "upload_max_filesize = 64M\\npost_max_size = 64M\\nmax_execution_time = 300\\nmemory_limit = 256M" > /usr/local/etc/php/conf.d/uploads.ini
|
|
${hasUploadedCode ? `COPY . /tmp/user-content
|
|
RUN mkdir -p /usr/src/wordpress-user
|
|
ENTRYPOINT ["cloudhost-entrypoint.sh"]
|
|
CMD []` : `CMD ["apache2-foreground"]`}
|
|
EXPOSE 80
|
|
`;
|
|
}
|
|
|
|
it('should use ENTRYPOINT (not CMD) when user uploaded code', () => {
|
|
const df = wordpressDockerfile({ codePath: '/some/path/source.zip' });
|
|
expect(df).toContain('ENTRYPOINT ["cloudhost-entrypoint.sh"]');
|
|
expect(df).not.toContain('CMD ["cloudhost-entrypoint.sh"]');
|
|
});
|
|
|
|
it('should use CMD apache2-foreground for fresh install (no code)', () => {
|
|
const df = wordpressDockerfile({});
|
|
expect(df).toContain('CMD ["apache2-foreground"]');
|
|
expect(df).not.toContain('ENTRYPOINT');
|
|
});
|
|
|
|
it('should use correct WordPress and PHP versions', () => {
|
|
const df = wordpressDockerfile({ runtimeVersion: '6.4', phpVersion: '8.2' });
|
|
expect(df).toContain('FROM wordpress:6.4-php8.2-apache');
|
|
});
|
|
|
|
it('should default to WP 6.7 and PHP 8.3', () => {
|
|
const df = wordpressDockerfile({});
|
|
expect(df).toContain('FROM wordpress:6.7-php8.3-apache');
|
|
});
|
|
|
|
it('should COPY user content when codePath exists', () => {
|
|
const df = wordpressDockerfile({ codePath: '/tmp/source.zip' });
|
|
expect(df).toContain('COPY . /tmp/user-content');
|
|
});
|
|
|
|
it('should NOT copy user content for fresh install', () => {
|
|
const df = wordpressDockerfile({});
|
|
expect(df).not.toContain('COPY . /tmp/user-content');
|
|
});
|
|
});
|
|
|
|
describe('Helper pod PVC race condition', () => {
|
|
it('should wait for pod deletion (not just fire-and-forget)', () => {
|
|
// Simulate the fix: after deleteNamespacedPod, poll readNamespacedPod until 404
|
|
const deletionSteps = [
|
|
{ exists: true }, // pod still terminating
|
|
{ exists: true }, // still terminating
|
|
{ exists: false }, // gone (404)
|
|
];
|
|
|
|
let pollCount = 0;
|
|
let fullyTerminated = false;
|
|
|
|
for (const step of deletionSteps) {
|
|
pollCount++;
|
|
if (!step.exists) {
|
|
fullyTerminated = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
expect(fullyTerminated).toBe(true);
|
|
expect(pollCount).toBe(3);
|
|
});
|
|
|
|
it('should time out if pod never terminates', () => {
|
|
const maxPolls = 30; // e.g. 60s / 2s interval
|
|
let pollCount = 0;
|
|
let timedOut = false;
|
|
|
|
while (pollCount < maxPolls) {
|
|
pollCount++;
|
|
// Pod always exists (simulating stuck termination)
|
|
const exists = true;
|
|
if (!exists) break;
|
|
}
|
|
|
|
if (pollCount >= maxPolls) {
|
|
timedOut = true;
|
|
}
|
|
|
|
expect(timedOut).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('WordPress entrypoint script', () => {
|
|
const entrypointScript = `#!/bin/bash
|
|
set -e
|
|
|
|
# Merge user wp-content into PVC
|
|
if [ -d /usr/src/wordpress-user/wp-content ]; then
|
|
mkdir -p /var/www/html/wp-content
|
|
cp -a /usr/src/wordpress-user/wp-content/. /var/www/html/wp-content/
|
|
chown -R www-data:www-data /var/www/html/wp-content
|
|
fi
|
|
|
|
exec docker-entrypoint.sh apache2-foreground`;
|
|
|
|
it('should call docker-entrypoint.sh exactly once (via exec)', () => {
|
|
const matches = entrypointScript.match(/docker-entrypoint\.sh/g);
|
|
expect(matches).toHaveLength(1);
|
|
});
|
|
|
|
it('should use exec to replace process', () => {
|
|
expect(entrypointScript).toContain('exec docker-entrypoint.sh apache2-foreground');
|
|
});
|
|
|
|
it('should merge wp-content on every start when staged content exists', () => {
|
|
expect(entrypointScript).toContain('/usr/src/wordpress-user/wp-content');
|
|
expect(entrypointScript).not.toContain('.user-content-merged');
|
|
});
|
|
|
|
it('should not copy user wp-config.php (credentials come from env vars)', () => {
|
|
expect(entrypointScript).not.toContain('wp-config.php');
|
|
});
|
|
|
|
it('should set proper ownership after merging wp-content', () => {
|
|
expect(entrypointScript).toContain('chown -R www-data:www-data /var/www/html/wp-content');
|
|
});
|
|
});
|
|
|
|
describe('WordPress zip structure handling', () => {
|
|
// The unzip init container handles single-subfolder flattening
|
|
it('should flatten single subfolder (public_html/) to root', () => {
|
|
// Simulate: zip contains only public_html/
|
|
const extractedItems = ['public_html'];
|
|
const count = extractedItems.length;
|
|
const firstItem = extractedItems[0];
|
|
|
|
let flattenedToRoot = false;
|
|
if (count === 1 && firstItem === 'public_html') {
|
|
// cp -a /tmp/extract/public_html/. /workspace-out/source/
|
|
flattenedToRoot = true;
|
|
}
|
|
|
|
expect(flattenedToRoot).toBe(true);
|
|
});
|
|
|
|
it('should copy as-is when multiple items exist', () => {
|
|
// Simulate: zip contains multiple items at root
|
|
const extractedItems = ['wp-admin', 'wp-content', 'wp-includes', 'index.php'];
|
|
const count = extractedItems.length;
|
|
|
|
let copiedAsIs = false;
|
|
if (count !== 1) {
|
|
copiedAsIs = true;
|
|
}
|
|
|
|
expect(copiedAsIs).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Nixpacks build preparation', () => {
|
|
// Local copies of the pure logic in build.service.ts (see NOTE at top of file).
|
|
function shellQuote(value: string): string {
|
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
}
|
|
|
|
function nixpacksPlanEnv(app: { runtime: AppRuntime; runtimeVersion?: string }): { name: string; value: string }[] {
|
|
const env: { name: string; value: string }[] = [];
|
|
if (app.runtime === AppRuntime.NODEJS && app.runtimeVersion) {
|
|
env.push({ name: 'NIXPACKS_NODE_VERSION', value: String(app.runtimeVersion) });
|
|
}
|
|
if ((app.runtime === AppRuntime.PYTHON || app.runtime === AppRuntime.DJANGO) && app.runtimeVersion) {
|
|
env.push({ name: 'NIXPACKS_PYTHON_VERSION', value: String(app.runtimeVersion) });
|
|
}
|
|
return env;
|
|
}
|
|
|
|
function nixpacksPrepareInitContainer(
|
|
app: { runtime: AppRuntime; runtimeVersion?: string },
|
|
config: { nixpacksImage?: string; nixpacksBuildEnv?: string[] } = {},
|
|
): any {
|
|
const image = config.nixpacksImage || 'ghcr.io/railwayapp/nixpacks:latest';
|
|
const buildEnv = config.nixpacksBuildEnv || [];
|
|
const envFlags = buildEnv.map((kv) => `--env ${shellQuote(kv)}`).join(' ');
|
|
const planEnv = nixpacksPlanEnv(app);
|
|
return {
|
|
name: 'nixpacks-prepare',
|
|
image,
|
|
env: planEnv.length ? planEnv : undefined,
|
|
command: [
|
|
'sh',
|
|
'-c',
|
|
`if [ -f source/Dockerfile ]; then cp source/Dockerfile /workspace/Dockerfile; ` +
|
|
`else nixpacks build source --out source ${envFlags} && cp source/.nixpacks/Dockerfile /workspace/Dockerfile; fi`,
|
|
],
|
|
volumeMounts: [{ name: 'workspace', mountPath: '/workspace' }],
|
|
};
|
|
}
|
|
|
|
it('prefers a user-provided Dockerfile (BYO), falling back to Nixpacks', () => {
|
|
const script = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS }).command[2] as string;
|
|
expect(script).toContain('if [ -f source/Dockerfile ]');
|
|
expect(script).toContain('cp source/Dockerfile /workspace/Dockerfile');
|
|
expect(script).toContain('nixpacks build source --out source');
|
|
expect(script).toContain('cp source/.nixpacks/Dockerfile /workspace/Dockerfile');
|
|
});
|
|
|
|
it('uses the configured Nixpacks image (default when unset)', () => {
|
|
expect(nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }).image).toBe('ghcr.io/railwayapp/nixpacks:latest');
|
|
expect(
|
|
nixpacksPrepareInitContainer({ runtime: AppRuntime.GO }, { nixpacksImage: 'registry.local/nixpacks:1.2.3' }).image,
|
|
).toBe('registry.local/nixpacks:1.2.3');
|
|
});
|
|
|
|
it('bakes build-time mirror env into the build via --env flags', () => {
|
|
const script = nixpacksPrepareInitContainer(
|
|
{ runtime: AppRuntime.NODEJS },
|
|
{ nixpacksBuildEnv: ['NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'] },
|
|
).command[2] as string;
|
|
expect(script).toContain(`--env 'NPM_CONFIG_REGISTRY=https://registry.npmmirror.com'`);
|
|
});
|
|
|
|
it('maps the selected Node version to NIXPACKS_NODE_VERSION', () => {
|
|
const c = nixpacksPrepareInitContainer({ runtime: AppRuntime.NODEJS, runtimeVersion: '20' });
|
|
expect(c.env).toContainEqual({ name: 'NIXPACKS_NODE_VERSION', value: '20' });
|
|
});
|
|
|
|
it('shellQuote escapes embedded single quotes safely', () => {
|
|
expect(shellQuote("a'b")).toBe("'a'\\''b'");
|
|
});
|
|
});
|