fix(platform): apply production hardening from audit plan
Close billing, tenancy, migration, build, and CI/CD gaps identified in the audit: wallet/gateway guards, full-UUID namespaces, idempotent migrations with base schema, stateful service stability, safer Dockerfiles/git builds, and platform chart hardening (Redis auth, RollingUpdate, backups, Swagger off). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -31,12 +31,14 @@ export class BuildCancelledError extends Error {
|
||||
|
||||
interface ActiveBuildSession {
|
||||
cancelled: boolean;
|
||||
applicationId?: string;
|
||||
coreApi?: k8s.CoreV1Api;
|
||||
batchApi?: k8s.BatchV1Api;
|
||||
namespace?: string;
|
||||
buildPodName?: string;
|
||||
sourcePvcName?: string;
|
||||
helperPodName?: string;
|
||||
gitSecretName?: string;
|
||||
processes: ChildProcess[];
|
||||
socket?: net.Socket;
|
||||
}
|
||||
@@ -68,8 +70,71 @@ export class BuildService {
|
||||
private sourceStorage: SourceStorageService,
|
||||
) {}
|
||||
|
||||
private beginBuildSession(deploymentId: string): void {
|
||||
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [] });
|
||||
/**
|
||||
* Prefix Docker Hub base images with the configured mirror registry
|
||||
* (BASE_IMAGE_REGISTRY), so generated Dockerfiles work on clusters that
|
||||
* cannot reach docker.io. Images already pinned to another registry
|
||||
* (gcr.io, mcr.microsoft.com, …) are returned unchanged.
|
||||
*/
|
||||
private baseImage(image: string): string {
|
||||
const prefix = this.configService.get<string>('build.baseImageRegistry');
|
||||
if (!prefix) return image;
|
||||
const firstSegment = image.split('/')[0];
|
||||
const hasRegistry = firstSegment.includes('.') || firstSegment.includes(':');
|
||||
if (hasRegistry) return image;
|
||||
return `${prefix}/${image}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Git branch names come from users and end up in a shell command — accept
|
||||
* only conservative ref characters and reject anything option-like.
|
||||
*/
|
||||
private assertSafeGitBranch(branch: string): string {
|
||||
const b = (branch || '').trim();
|
||||
if (!b || b.length > 255 || b.startsWith('-') || b.includes('..') || !/^[A-Za-z0-9._/-]+$/.test(b)) {
|
||||
throw new Error(`Invalid git branch name: "${branch}"`);
|
||||
}
|
||||
return b;
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF guard for user-supplied repo URLs: only http(s), no embedded
|
||||
* credentials, and no loopback/link-local/private or cluster-internal hosts.
|
||||
*/
|
||||
private assertSafeGitUrl(gitUrl: string): void {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(gitUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid git URL: "${gitUrl}"`);
|
||||
}
|
||||
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
||||
throw new Error(`Unsupported git URL protocol: "${url.protocol}" — only http(s) is allowed`);
|
||||
}
|
||||
if (url.username || url.password) {
|
||||
throw new Error('Git URL must not contain embedded credentials — use the git token field instead');
|
||||
}
|
||||
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||
const blockedHosts = ['localhost', 'metadata.google.internal'];
|
||||
const blockedSuffixes = ['.local', '.localhost', '.internal', '.svc', '.svc.cluster.local', '.cluster.local'];
|
||||
const isPrivateIPv4 =
|
||||
/^(127\.|10\.|192\.168\.|169\.254\.|0\.)/.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
|
||||
const isIPv6Internal = host === '::1' || host.startsWith('fe80:') || host.startsWith('fc') || host.startsWith('fd');
|
||||
if (
|
||||
blockedHosts.includes(host) ||
|
||||
blockedSuffixes.some((s) => host.endsWith(s)) ||
|
||||
isPrivateIPv4 ||
|
||||
isIPv6Internal ||
|
||||
!host.includes('.')
|
||||
) {
|
||||
throw new Error(`Git URL host "${url.hostname}" is not allowed`);
|
||||
}
|
||||
}
|
||||
|
||||
private beginBuildSession(deploymentId: string, applicationId?: string): void {
|
||||
this.activeBuilds.set(deploymentId, { cancelled: false, processes: [], applicationId });
|
||||
this.persistSession(deploymentId);
|
||||
}
|
||||
|
||||
private getSession(deploymentId?: string): ActiveBuildSession | undefined {
|
||||
@@ -80,6 +145,26 @@ export class BuildService {
|
||||
private updateBuildSession(deploymentId: string, update: Partial<ActiveBuildSession>): void {
|
||||
const session = this.activeBuilds.get(deploymentId);
|
||||
if (session) Object.assign(session, update);
|
||||
this.persistSession(deploymentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror the serializable part of the session to Redis, so interrupted
|
||||
* builds can be detected and their cluster resources cleaned up after a
|
||||
* backend restart (the in-memory map does not survive restarts).
|
||||
*/
|
||||
private persistSession(deploymentId: string): void {
|
||||
const session = this.activeBuilds.get(deploymentId);
|
||||
if (!session) return;
|
||||
void this.progressStore.setSession({
|
||||
deploymentId,
|
||||
applicationId: session.applicationId,
|
||||
namespace: session.namespace,
|
||||
buildPodName: session.buildPodName,
|
||||
sourcePvcName: session.sourcePvcName,
|
||||
helperPodName: session.helperPodName,
|
||||
gitSecretName: session.gitSecretName,
|
||||
});
|
||||
}
|
||||
|
||||
private registerProcess(deploymentId: string | undefined, proc: ChildProcess): void {
|
||||
@@ -122,7 +207,10 @@ export class BuildService {
|
||||
}
|
||||
|
||||
private endBuildSession(deploymentId?: string): void {
|
||||
if (deploymentId) this.activeBuilds.delete(deploymentId);
|
||||
if (deploymentId) {
|
||||
this.activeBuilds.delete(deploymentId);
|
||||
void this.progressStore.clearSession(deploymentId);
|
||||
}
|
||||
}
|
||||
|
||||
async cancelBuild(deploymentId: string): Promise<void> {
|
||||
@@ -154,7 +242,7 @@ export class BuildService {
|
||||
}
|
||||
}
|
||||
|
||||
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName } = session;
|
||||
const { coreApi, batchApi, namespace, buildPodName, sourcePvcName, helperPodName, gitSecretName } = session;
|
||||
if (coreApi && namespace) {
|
||||
const cleanup: Promise<unknown>[] = [];
|
||||
if (helperPodName) {
|
||||
@@ -200,6 +288,11 @@ export class BuildService {
|
||||
.catch(() => undefined),
|
||||
);
|
||||
}
|
||||
if (gitSecretName) {
|
||||
cleanup.push(
|
||||
coreApi.deleteNamespacedSecret({ name: gitSecretName, namespace }).catch(() => undefined),
|
||||
);
|
||||
}
|
||||
await Promise.all(cleanup);
|
||||
this.logger.log(`Cleaned up K8s build resources for deployment ${deploymentId}`);
|
||||
}
|
||||
@@ -209,7 +302,7 @@ export class BuildService {
|
||||
percent: 0,
|
||||
message: 'Cancelled by user',
|
||||
});
|
||||
this.activeBuilds.delete(deploymentId);
|
||||
this.endBuildSession(deploymentId);
|
||||
}
|
||||
|
||||
/** Delete all in-flight build artifacts for an app (helper pods, jobs, PVCs, configmaps). */
|
||||
@@ -226,13 +319,14 @@ export class BuildService {
|
||||
|
||||
const cleanup: Promise<unknown>[] = [];
|
||||
|
||||
const [pods, pvcs, jobs, configMaps] = await Promise.all([
|
||||
const [pods, pvcs, jobs, configMaps, secrets] = await Promise.all([
|
||||
coreApi.listNamespacedPod({ namespace: buildNamespace }),
|
||||
coreApi.listNamespacedPersistentVolumeClaim({
|
||||
namespace: buildNamespace,
|
||||
}),
|
||||
batchApi.listNamespacedJob({ namespace: buildNamespace }),
|
||||
coreApi.listNamespacedConfigMap({ namespace: buildNamespace }),
|
||||
coreApi.listNamespacedSecret({ namespace: buildNamespace }),
|
||||
]);
|
||||
|
||||
for (const pod of pods.items) {
|
||||
@@ -283,6 +377,12 @@ export class BuildService {
|
||||
cleanup.push(coreApi.deleteNamespacedConfigMap({ name, namespace: buildNamespace }).catch(() => undefined));
|
||||
}
|
||||
}
|
||||
for (const secret of secrets.items) {
|
||||
const name = secret.metadata?.name || '';
|
||||
if (name.startsWith(prefix)) {
|
||||
cleanup.push(coreApi.deleteNamespacedSecret({ name, namespace: buildNamespace }).catch(() => undefined));
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all(cleanup);
|
||||
this.logger.log(`Cleaned up all build resources matching "${prefix}*" in ${buildNamespace}`);
|
||||
@@ -320,7 +420,7 @@ export class BuildService {
|
||||
this.logger.log(`Starting image build for ${app.name} → ${imageUri}`);
|
||||
|
||||
if (deploymentId) {
|
||||
this.beginBuildSession(deploymentId);
|
||||
this.beginBuildSession(deploymentId, app.id);
|
||||
}
|
||||
|
||||
const hasUploadedCode = !!app.codePath;
|
||||
@@ -389,6 +489,8 @@ export class BuildService {
|
||||
|
||||
// If we have uploaded code, create a PVC and upload via kubectl cp
|
||||
let sourcePvcName: string | undefined;
|
||||
// Secret holding the git token for private-repo clones (created lazily)
|
||||
let gitSecretName: string | undefined;
|
||||
if (hasUploadedCode && localZipPath) {
|
||||
sourcePvcName = `${buildPodName}-source`;
|
||||
if (deploymentId) {
|
||||
@@ -446,7 +548,7 @@ export class BuildService {
|
||||
// Add init container that unzips the source code from PVC
|
||||
initContainers.push({
|
||||
name: 'unzip-source',
|
||||
image: 'alpine:3.19',
|
||||
image: this.baseImage('alpine:3.19'),
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: [
|
||||
'sh',
|
||||
@@ -493,36 +595,60 @@ export class BuildService {
|
||||
],
|
||||
});
|
||||
} else if (hasGitUrl) {
|
||||
// Build the git clone URL — inject token for private repos
|
||||
let cloneUrl = app.gitUrl!;
|
||||
// Validate user-controlled values before they get anywhere near a shell.
|
||||
this.assertSafeGitUrl(app.gitUrl!);
|
||||
const branch = this.assertSafeGitBranch(app.gitBranch || 'main');
|
||||
|
||||
// The token never appears in the command line or the clone URL — it is
|
||||
// delivered via a Secret env var and handed to git through GIT_ASKPASS,
|
||||
// so it can't leak through pod specs, `ps`, or job logs.
|
||||
if (app.gitToken) {
|
||||
// Convert https://github.com/user/repo.git → https://<token>@github.com/user/repo.git
|
||||
// Also works for GitLab, Bitbucket, etc.
|
||||
try {
|
||||
const url = new URL(cloneUrl);
|
||||
url.username = app.gitToken;
|
||||
url.password = ''; // Some providers use token as username, others as password
|
||||
cloneUrl = url.toString();
|
||||
} catch {
|
||||
// If URL parsing fails, try simple injection after protocol
|
||||
cloneUrl = cloneUrl.replace('https://', `https://${app.gitToken}@`);
|
||||
}
|
||||
gitSecretName = `${buildPodName}-git`;
|
||||
if (deploymentId) this.updateBuildSession(deploymentId, { gitSecretName });
|
||||
await coreApi.createNamespacedSecret({
|
||||
namespace: buildNamespace!,
|
||||
body: {
|
||||
apiVersion: 'v1',
|
||||
kind: 'Secret',
|
||||
metadata: { name: gitSecretName, namespace: buildNamespace },
|
||||
type: 'Opaque',
|
||||
stringData: { GIT_TOKEN: app.gitToken },
|
||||
},
|
||||
});
|
||||
}
|
||||
const branch = app.gitBranch || 'main';
|
||||
|
||||
// Clone git repo into /workspace/source, then copy our generated Dockerfile
|
||||
initContainers.push({
|
||||
name: 'git-clone',
|
||||
image: 'alpine/git:2.43.0',
|
||||
image: this.baseImage('alpine/git:2.43.0'),
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
env: [
|
||||
{ name: 'GIT_URL', value: app.gitUrl! },
|
||||
{ name: 'GIT_BRANCH', value: branch },
|
||||
...(gitSecretName
|
||||
? [
|
||||
{
|
||||
name: 'GIT_TOKEN',
|
||||
valueFrom: { secretKeyRef: { name: gitSecretName, key: 'GIT_TOKEN' } },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
command: [
|
||||
'sh',
|
||||
'-c',
|
||||
`
|
||||
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
|
||||
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
|
||||
echo ">>> Workspace contents:" &&
|
||||
set -e
|
||||
if [ -n "\${GIT_TOKEN:-}" ]; then
|
||||
printf '#!/bin/sh\\necho "$GIT_TOKEN"\\n' > /tmp/git-askpass.sh
|
||||
chmod +x /tmp/git-askpass.sh
|
||||
export GIT_ASKPASS=/tmp/git-askpass.sh
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
fi
|
||||
echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL"
|
||||
git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source
|
||||
cp /dockerfile/Dockerfile /workspace-out/Dockerfile
|
||||
echo ">>> Workspace contents:"
|
||||
ls -la /workspace-out/source/
|
||||
`,
|
||||
],
|
||||
@@ -545,7 +671,7 @@ export class BuildService {
|
||||
// add an init container that creates empty source dir + copies Dockerfile
|
||||
initContainers.push({
|
||||
name: 'prepare-workspace',
|
||||
image: 'alpine:3.19',
|
||||
image: this.baseImage('alpine:3.19'),
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: [
|
||||
'sh',
|
||||
@@ -586,8 +712,14 @@ export class BuildService {
|
||||
args: kanikoArgs,
|
||||
volumeMounts: kanikoVolumeMounts,
|
||||
resources: {
|
||||
requests: { cpu: '500m', memory: '1Gi' },
|
||||
limits: { cpu: '2', memory: '4Gi' },
|
||||
requests: {
|
||||
cpu: this.configService.get<string>('build.kaniko.cpuRequest') || '500m',
|
||||
memory: this.configService.get<string>('build.kaniko.memoryRequest') || '1Gi',
|
||||
},
|
||||
limits: {
|
||||
cpu: this.configService.get<string>('build.kaniko.cpuLimit') || '2',
|
||||
memory: this.configService.get<string>('build.kaniko.memoryLimit') || '4Gi',
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -666,6 +798,17 @@ export class BuildService {
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
|
||||
}
|
||||
// Clean up git-token Secret
|
||||
if (gitSecretName) {
|
||||
try {
|
||||
await coreApi.deleteNamespacedSecret({
|
||||
name: gitSecretName,
|
||||
namespace: buildNamespace!,
|
||||
});
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to clean up git Secret: ${e.message}`);
|
||||
}
|
||||
}
|
||||
this.endBuildSession(deploymentId);
|
||||
cleanupSource?.();
|
||||
}
|
||||
@@ -780,6 +923,8 @@ export class BuildService {
|
||||
metadata: { name: pvcName, namespace },
|
||||
spec: {
|
||||
accessModes: ['ReadWriteOnce'],
|
||||
// Explicit StorageClass — don't rely on a cluster default existing
|
||||
storageClassName: this.configService.get<string>('platform.storageClass') || undefined,
|
||||
resources: { requests: { storage: `${sizeGi}Gi` } },
|
||||
},
|
||||
},
|
||||
@@ -797,7 +942,7 @@ export class BuildService {
|
||||
containers: [
|
||||
{
|
||||
name: 'helper',
|
||||
image: 'alpine:3.19',
|
||||
image: this.baseImage('alpine:3.19'),
|
||||
imagePullPolicy: 'IfNotPresent',
|
||||
command: ['sh', '-c', 'sleep 3600'],
|
||||
volumeMounts: [{ name: 'source', mountPath: '/data' }],
|
||||
@@ -1012,10 +1157,12 @@ export class BuildService {
|
||||
const port = app.port || 3000;
|
||||
const nodeVersion = app.runtimeVersion || '20';
|
||||
return `# --- Build stage ---
|
||||
FROM node:${nodeVersion}-alpine AS builder
|
||||
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm install --legacy-peer-deps && npm cache clean --force
|
||||
# Reproducible install from the lockfile when present
|
||||
RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\
|
||||
&& npm cache clean --force
|
||||
COPY . .
|
||||
|
||||
# Auto-detect Next.js and enable standalone output
|
||||
@@ -1031,13 +1178,19 @@ RUN for cfg in next.config.js next.config.mjs next.config.ts; do \\
|
||||
break; \\
|
||||
done
|
||||
|
||||
RUN npm run build || echo ">>> Build script failed or not found — continuing"
|
||||
# Run the build script when one exists — and FAIL the image build if it fails,
|
||||
# instead of silently shipping a broken image.
|
||||
RUN if node -e "const s=(require('./package.json').scripts||{});process.exit(s.build?0:1)"; then \\
|
||||
echo ">>> Running build script" && npm run build; \\
|
||||
else \\
|
||||
echo ">>> No build script defined — skipping"; \\
|
||||
fi
|
||||
|
||||
# Clean up dev dependencies and caches to reduce image size
|
||||
RUN rm -rf node_modules/.cache .next/cache /tmp/* /root/.npm 2>/dev/null; true
|
||||
|
||||
# --- Production stage ---
|
||||
FROM node:${nodeVersion}-alpine AS runner
|
||||
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS runner
|
||||
WORKDIR /app
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001
|
||||
|
||||
@@ -1070,9 +1223,9 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `# --- Build stage (match production PHP version for Composer) ---
|
||||
FROM php:${phpVersion}-cli-alpine AS composer
|
||||
FROM ${this.baseImage(`php:${phpVersion}-cli-alpine`)} AS composer
|
||||
RUN apk add --no-cache git unzip
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
COPY --from=${this.baseImage('composer:2')} /usr/bin/composer /usr/bin/composer
|
||||
WORKDIR /app
|
||||
COPY composer.json composer.lock* ./
|
||||
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --ignore-platform-reqs
|
||||
@@ -1080,11 +1233,15 @@ COPY . .
|
||||
RUN composer dump-autoload --optimize --no-dev --no-scripts
|
||||
|
||||
# --- Production stage ---
|
||||
FROM php:${phpVersion}-fpm-alpine
|
||||
FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
|
||||
|
||||
RUN apk add --no-cache nginx supervisor curl openssl \\
|
||||
&& docker-php-ext-install pdo pdo_mysql opcache \\
|
||||
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
|
||||
# Laravel needs bcmath/gd/intl/zip beyond the built-in set; pdo_pgsql is built
|
||||
# properly against libpq instead of being silently skipped.
|
||||
RUN apk add --no-cache nginx supervisor curl openssl icu-libs libzip libpng libjpeg-turbo freetype postgresql-libs \\
|
||||
&& apk add --no-cache --virtual .build-deps icu-dev libzip-dev libpng-dev libjpeg-turbo-dev freetype-dev postgresql-dev \\
|
||||
&& docker-php-ext-configure gd --with-jpeg --with-freetype \\
|
||||
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache bcmath zip gd intl exif pcntl \\
|
||||
&& apk del .build-deps
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY --from=composer /app .
|
||||
@@ -1163,7 +1320,7 @@ CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"]
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const hasUploadedCode = !!app.codePath;
|
||||
|
||||
return `FROM wordpress:${wpVersion}-php${phpVersion}-apache
|
||||
return `FROM ${this.baseImage(`wordpress:${wpVersion}-php${phpVersion}-apache`)}
|
||||
|
||||
# Install additional PHP extensions commonly needed by WordPress
|
||||
RUN docker-php-ext-install opcache
|
||||
@@ -1281,7 +1438,7 @@ CMD []`
|
||||
const port = app.port || 8080;
|
||||
const buildTarget = detectGoBuildTarget(archiveEntries);
|
||||
return `# --- Build stage ---
|
||||
FROM golang:${goVersion}-alpine AS builder
|
||||
FROM ${this.baseImage(`golang:${goVersion}-alpine`)} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install git for fetching dependencies
|
||||
@@ -1297,8 +1454,13 @@ COPY . .
|
||||
# Build the application
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -ldflags="-w -s" -o main ${buildTarget}
|
||||
|
||||
# Collect optional runtime asset dirs — COPY has no shell so "|| true" is not
|
||||
# valid there; stage them in the builder instead.
|
||||
RUN mkdir -p /assets \\
|
||||
&& for d in static templates public; do [ -d "$d" ] && cp -r "$d" /assets/ || true; done
|
||||
|
||||
# --- Production stage ---
|
||||
FROM alpine:3.19
|
||||
FROM ${this.baseImage('alpine:3.19')}
|
||||
WORKDIR /app
|
||||
|
||||
# Add CA certificates for HTTPS requests
|
||||
@@ -1307,11 +1469,9 @@ RUN apk --no-cache add ca-certificates tzdata
|
||||
# Create non-root user
|
||||
RUN addgroup -g 1001 -S appgroup && adduser -S appuser -u 1001 -G appgroup
|
||||
|
||||
# Copy the binary from builder
|
||||
# Copy the binary and any staged asset dirs from the builder
|
||||
COPY --from=builder /app/main .
|
||||
COPY --from=builder /app/static ./static 2>/dev/null || true
|
||||
COPY --from=builder /app/templates ./templates 2>/dev/null || true
|
||||
COPY --from=builder /app/public ./public 2>/dev/null || true
|
||||
COPY --from=builder /assets/ ./
|
||||
|
||||
# Create data directory for persistent storage
|
||||
RUN mkdir -p /app/data && chown -R appuser:appgroup /app
|
||||
@@ -1331,16 +1491,14 @@ CMD ["./main"]
|
||||
private phpDockerfile(app: Application): string {
|
||||
const phpVersion = app.phpVersion || '8.3';
|
||||
const port = app.port || 80;
|
||||
return `FROM php:${phpVersion}-fpm-alpine
|
||||
return `FROM ${this.baseImage(`php:${phpVersion}-fpm-alpine`)}
|
||||
|
||||
RUN apk add --no-cache nginx supervisor curl \\
|
||||
&& docker-php-ext-install pdo pdo_mysql opcache \\
|
||||
&& docker-php-ext-install pdo_pgsql 2>/dev/null || true
|
||||
|
||||
# Install common PHP extensions
|
||||
RUN apk add --no-cache libpng-dev libjpeg-turbo-dev freetype-dev \\
|
||||
# Install common PHP extensions (pdo_pgsql built properly against libpq)
|
||||
RUN apk add --no-cache nginx supervisor curl postgresql-libs libpng libjpeg-turbo freetype \\
|
||||
&& apk add --no-cache --virtual .build-deps postgresql-dev libpng-dev libjpeg-turbo-dev freetype-dev \\
|
||||
&& docker-php-ext-configure gd --with-freetype --with-jpeg \\
|
||||
&& docker-php-ext-install gd
|
||||
&& docker-php-ext-install -j$(nproc) pdo pdo_mysql pdo_pgsql opcache gd \\
|
||||
&& apk del .build-deps
|
||||
|
||||
WORKDIR /var/www/html
|
||||
COPY . .
|
||||
@@ -1401,7 +1559,7 @@ CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
||||
const pythonVersion = app.runtimeVersion || '3.12';
|
||||
const port = app.port || 8000;
|
||||
return `# --- Build stage ---
|
||||
FROM python:${pythonVersion}-slim AS builder
|
||||
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
@@ -1409,13 +1567,20 @@ RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
build-essential libpq-dev \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
|
||||
pip install --no-cache-dir --user flask gunicorn
|
||||
# Install dependencies from requirements.txt or pyproject.toml. A failing
|
||||
# install FAILS the build — no silent fallback that hides missing deps.
|
||||
COPY . .
|
||||
RUN if [ -f requirements.txt ]; then \\
|
||||
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
|
||||
elif [ -f pyproject.toml ]; then \\
|
||||
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
|
||||
else \\
|
||||
echo ">>> No requirements.txt or pyproject.toml — installing default flask+gunicorn" \\
|
||||
&& pip install --no-cache-dir --user flask gunicorn; \\
|
||||
fi
|
||||
|
||||
# --- Production stage ---
|
||||
FROM python:${pythonVersion}-slim
|
||||
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
@@ -1455,21 +1620,28 @@ CMD sh -c "if [ -f main.py ]; then if grep -qi fastapi main.py; then exec uvicor
|
||||
const port = app.port || 8000;
|
||||
const settingsModule = detectDjangoSettingsModule(archiveEntries);
|
||||
return `# --- Build stage ---
|
||||
FROM python:${pythonVersion}-slim AS builder
|
||||
FROM ${this.baseImage(`python:${pythonVersion}-slim`)} AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \\
|
||||
build-essential libpq-dev \\
|
||||
build-essential libpq-dev default-libmysqlclient-dev pkg-config \\
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements and install dependencies
|
||||
COPY requirements.txt* ./
|
||||
RUN pip install --no-cache-dir --user -r requirements.txt 2>/dev/null || \\
|
||||
pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient
|
||||
# Install dependencies from requirements.txt or pyproject.toml. A failing
|
||||
# install FAILS the build — no silent fallback that hides missing deps.
|
||||
COPY . .
|
||||
RUN if [ -f requirements.txt ]; then \\
|
||||
echo ">>> Installing from requirements.txt" && pip install --no-cache-dir --user -r requirements.txt; \\
|
||||
elif [ -f pyproject.toml ]; then \\
|
||||
echo ">>> Installing from pyproject.toml" && pip install --no-cache-dir --user .; \\
|
||||
else \\
|
||||
echo ">>> No requirements.txt or pyproject.toml — installing Django defaults" \\
|
||||
&& pip install --no-cache-dir --user django gunicorn psycopg2-binary mysqlclient; \\
|
||||
fi
|
||||
|
||||
# --- Production stage ---
|
||||
FROM python:${pythonVersion}-slim
|
||||
FROM ${this.baseImage(`python:${pythonVersion}-slim`)}
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
|
||||
Reference in New Issue
Block a user