Harden platform security, reliability, and CI after full audit.
Close deployment IDOR and gate stub payment endpoints, add production secret validation, health probes, Redis-backed build progress, GitHub Actions CI, expanded tests, billing/k8s refactors, and ops runbooks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,469 +1,68 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { BuildService } from './build.service';
|
||||
import { Application } from '../applications/entities/application.entity';
|
||||
import { AppRuntime } from '../common/enums';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { RegistryService } from '../kubernetes/registry.service';
|
||||
import { BuildProgressStore } from './build-progress.store';
|
||||
|
||||
/**
|
||||
* Tests for build service — Dockerfile generation for all runtimes
|
||||
*/
|
||||
describe('BuildService', () => {
|
||||
let service: BuildService;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Go Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Go Dockerfile generation', () => {
|
||||
function goDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const goVersion = app.runtimeVersion || '1.22';
|
||||
const port = app.port || 8080;
|
||||
return `FROM golang:${goVersion}-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache git
|
||||
COPY go.mod go.sum* ./
|
||||
RUN go mod download || true
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main .
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
BuildService,
|
||||
{
|
||||
provide: ConfigService,
|
||||
useValue: {
|
||||
get: jest.fn((key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
'build.namespace': 'cloudhost-builds',
|
||||
'build.serviceAccount': 'kaniko-builder',
|
||||
'registry.url': 'registry.local:5000',
|
||||
};
|
||||
return map[key];
|
||||
}),
|
||||
},
|
||||
},
|
||||
{ provide: ClustersService, useValue: {} },
|
||||
{
|
||||
provide: BuildProgressStore,
|
||||
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
|
||||
},
|
||||
{ provide: RegistryService, useValue: {} },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
FROM alpine:3.19
|
||||
WORKDIR /app
|
||||
RUN apk --no-cache add ca-certificates tzdata
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
|
||||
COPY --from=builder /app/main .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD wget --no-verbose --tries=1 --spider http://localhost:${port}/health || exit 1
|
||||
CMD ["./main"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Go version', () => {
|
||||
const df = goDockerfile({ runtimeVersion: '1.21' });
|
||||
expect(df).toContain('FROM golang:1.21-alpine');
|
||||
service = module.get(BuildService);
|
||||
});
|
||||
|
||||
it('should default to Go 1.22', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('FROM golang:1.22-alpine');
|
||||
});
|
||||
describe('generateDockerfile', () => {
|
||||
it('generates Go Dockerfile with requested runtime version', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.GO,
|
||||
runtimeVersion: '1.22',
|
||||
port: 8080,
|
||||
} as Application;
|
||||
|
||||
it('should build static binary with CGO_ENABLED=0', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('CGO_ENABLED=0');
|
||||
});
|
||||
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||
|
||||
it('should use multi-stage build for smaller image', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
expect(df).toContain('FROM alpine:3.19');
|
||||
});
|
||||
expect(dockerfile).toContain('FROM golang:1.22-alpine');
|
||||
expect(dockerfile).toContain('EXPOSE 8080');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = goDockerfile({ port: 8080 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8080/health');
|
||||
});
|
||||
it('generates Node.js Dockerfile with default port', () => {
|
||||
const app = {
|
||||
runtime: AppRuntime.NODEJS,
|
||||
runtimeVersion: '20',
|
||||
} as Application;
|
||||
|
||||
it('should create data directory for persistent storage', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('mkdir -p /app/data');
|
||||
});
|
||||
const dockerfile = (service as any).generateDockerfile(app) as string;
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = goDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Python Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Python Dockerfile generation', () => {
|
||||
function pythonDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user flask gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
RUN groupadd -g 1001 appgroup && useradd -r -u 1001 -g appgroup appuser
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
USER appuser
|
||||
ENV PATH=/home/appuser/.local/bin:$PATH
|
||||
ENV PORT=${port}
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:${port}", "app:app"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = pythonDockerfile({ runtimeVersion: '3.11' });
|
||||
expect(df).toContain('FROM python:3.11-slim');
|
||||
});
|
||||
|
||||
it('should default to Python 3.12', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('FROM python:3.12-slim');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('AS builder');
|
||||
});
|
||||
|
||||
it('should install from requirements.txt', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('requirements.txt');
|
||||
});
|
||||
|
||||
it('should include health check', () => {
|
||||
const df = pythonDockerfile({ port: 8000 });
|
||||
expect(df).toContain('HEALTHCHECK');
|
||||
expect(df).toContain('http://localhost:8000/health');
|
||||
});
|
||||
|
||||
it('should run as non-root user', () => {
|
||||
const df = pythonDockerfile({});
|
||||
expect(df).toContain('USER appuser');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Django Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('Django Dockerfile generation', () => {
|
||||
function djangoDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `FROM python:${pythonVersion}-slim AS builder
|
||||
WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y build-essential libpq-dev
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || pip install --no-cache-dir --user django gunicorn
|
||||
|
||||
FROM python:${pythonVersion}-slim
|
||||
WORKDIR /app
|
||||
COPY --from=builder /root/.local /home/appuser/.local
|
||||
COPY . .
|
||||
RUN mkdir -p /app/staticfiles /app/media /app/data
|
||||
USER appuser
|
||||
ENV PORT=${port}
|
||||
ENV DJANGO_SETTINGS_MODULE=config.settings
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health/ || exit 1
|
||||
CMD ["sh", "-c", "python manage.py migrate --noinput && gunicorn config.wsgi:application --bind 0.0.0.0:${port}"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct Python version', () => {
|
||||
const df = djangoDockerfile({ runtimeVersion: '3.10' });
|
||||
expect(df).toContain('FROM python:3.10-slim');
|
||||
});
|
||||
|
||||
it('should set DJANGO_SETTINGS_MODULE', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('DJANGO_SETTINGS_MODULE');
|
||||
});
|
||||
|
||||
it('should create staticfiles and media directories', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('/app/staticfiles');
|
||||
expect(df).toContain('/app/media');
|
||||
});
|
||||
|
||||
it('should run migrations on startup', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('migrate');
|
||||
});
|
||||
|
||||
it('should use gunicorn for production', () => {
|
||||
const df = djangoDockerfile({});
|
||||
expect(df).toContain('gunicorn');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// .NET Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('.NET Dockerfile generation', () => {
|
||||
function dotnetDockerfile(app: { runtimeVersion?: string; port?: number }): string {
|
||||
const dotnetVersion = app.runtimeVersion || '8.0';
|
||||
const port = app.port || 5000;
|
||||
return `FROM mcr.microsoft.com/dotnet/sdk:${dotnetVersion} AS build
|
||||
WORKDIR /src
|
||||
COPY *.csproj ./
|
||||
RUN dotnet restore || true
|
||||
COPY . .
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:${dotnetVersion}
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
RUN mkdir -p /app/data
|
||||
USER appuser
|
||||
ENV ASPNETCORE_URLS=http://+:${port}
|
||||
ENV ASPNETCORE_ENVIRONMENT=Production
|
||||
EXPOSE ${port}
|
||||
HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:${port}/health || exit 1
|
||||
CMD ["dotnet", "app.dll"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct .NET version', () => {
|
||||
const df = dotnetDockerfile({ runtimeVersion: '7.0' });
|
||||
expect(df).toContain('dotnet/sdk:7.0');
|
||||
expect(df).toContain('dotnet/aspnet:7.0');
|
||||
});
|
||||
|
||||
it('should default to .NET 8.0', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('dotnet/sdk:8.0');
|
||||
});
|
||||
|
||||
it('should use multi-stage build', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('AS build');
|
||||
expect(df).toContain('dotnet/aspnet');
|
||||
});
|
||||
|
||||
it('should publish in Release mode', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('-c Release');
|
||||
});
|
||||
|
||||
it('should set ASPNETCORE_ENVIRONMENT to Production', () => {
|
||||
const df = dotnetDockerfile({});
|
||||
expect(df).toContain('ASPNETCORE_ENVIRONMENT=Production');
|
||||
});
|
||||
|
||||
it('should configure ASPNETCORE_URLS for correct port', () => {
|
||||
const df = dotnetDockerfile({ port: 8080 });
|
||||
expect(df).toContain('ASPNETCORE_URLS=http://+:8080');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// PHP Dockerfile tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
describe('PHP Dockerfile generation', () => {
|
||||
function phpDockerfile(app: { phpVersion?: string; port?: number }): string {
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `FROM php:${phpVersion}-fpm-alpine
|
||||
RUN apk add --no-cache nginx supervisor curl
|
||||
RUN docker-php-ext-install pdo pdo_mysql opcache
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
RUN mkdir -p /var/www/html/uploads /var/www/html/data
|
||||
RUN chown -R www-data:www-data /var/www/html
|
||||
EXPOSE ${port}
|
||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
`;
|
||||
}
|
||||
|
||||
it('should use correct PHP version', () => {
|
||||
const df = phpDockerfile({ phpVersion: '8.2' });
|
||||
expect(df).toContain('FROM php:8.2-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should default to PHP 8.3', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('FROM php:8.3-fpm-alpine');
|
||||
});
|
||||
|
||||
it('should use FPM with nginx via supervisord', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('supervisord');
|
||||
expect(df).toContain('nginx');
|
||||
});
|
||||
|
||||
it('should install common PHP extensions', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('pdo');
|
||||
expect(df).toContain('opcache');
|
||||
});
|
||||
|
||||
it('should create upload and data directories', () => {
|
||||
const df = phpDockerfile({});
|
||||
expect(df).toContain('/var/www/html/uploads');
|
||||
expect(df).toContain('/var/www/html/data');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 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);
|
||||
expect(dockerfile).toContain('FROM node:20');
|
||||
expect(dockerfile).toContain('EXPOSE 3000');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user