From cca6cdc1a5a9f728e6e28779a1df865d7fbfc531 Mon Sep 17 00:00:00 2001 From: keyhan Date: Thu, 14 May 2026 18:48:03 +0330 Subject: [PATCH] Improve Laravel deploy support and add .env import to deploy wizard. Set Laravel DB env vars in Helm and Kubernetes, fix default port to 80 with a storage-aware entrypoint, and let users upload a .env file during deploy. Co-authored-by: Cursor --- .../cloudhost-app/templates/deployment.yaml | 36 ++++++++++ backend/src/build/build.service.ts | 12 +++- backend/src/kubernetes/kubernetes.service.ts | 10 +++ frontend/src/app/dashboard/deploy/page.tsx | 68 ++++++++++++++++++- frontend/src/lib/parseDotenv.ts | 49 +++++++++++++ 5 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 frontend/src/lib/parseDotenv.ts diff --git a/backend/helm/cloudhost-app/templates/deployment.yaml b/backend/helm/cloudhost-app/templates/deployment.yaml index 0b1da0e..07adfd3 100644 --- a/backend/helm/cloudhost-app/templates/deployment.yaml +++ b/backend/helm/cloudhost-app/templates/deployment.yaml @@ -53,6 +53,15 @@ spec: key: password - name: DATABASE_URL value: "postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:5432/{{ include "cloudhost-app.dbName" . }}" + {{- if or (eq .Values.app.runtime "laravel") (eq .Values.app.runtime "php") }} + - name: DB_DATABASE + value: {{ include "cloudhost-app.dbName" . }} + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + {{- end }} {{- end }} {{- if and .Values.database.enabled (eq .Values.database.type "mysql") }} - name: DB_HOST @@ -73,6 +82,15 @@ spec: key: password - name: DATABASE_URL value: "mysql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:3306/{{ include "cloudhost-app.dbName" . }}" + {{- if or (eq .Values.app.runtime "laravel") (eq .Values.app.runtime "php") }} + - name: DB_DATABASE + value: {{ include "cloudhost-app.dbName" . }} + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + {{- end }} {{- end }} {{- if and .Values.database.enabled (eq .Values.database.type "mariadb") }} - name: DB_HOST @@ -93,6 +111,15 @@ spec: key: password - name: DATABASE_URL value: "mysql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:3306/{{ include "cloudhost-app.dbName" . }}" + {{- if or (eq .Values.app.runtime "laravel") (eq .Values.app.runtime "php") }} + - name: DB_DATABASE + value: {{ include "cloudhost-app.dbName" . }} + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + {{- end }} {{- end }} {{- if and .Values.database.enabled (eq .Values.database.type "mongodb") }} - name: DB_HOST @@ -115,6 +142,15 @@ spec: value: "mongodb://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:27017/{{ include "cloudhost-app.dbName" . }}?authSource=admin" - name: DATABASE_URL value: "mongodb://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:27017/{{ include "cloudhost-app.dbName" . }}?authSource=admin" + {{- if or (eq .Values.app.runtime "laravel") (eq .Values.app.runtime "php") }} + - name: DB_DATABASE + value: {{ include "cloudhost-app.dbName" . }} + - name: DB_USERNAME + valueFrom: + secretKeyRef: + name: {{ $name }}-db-secret + key: username + {{- end }} {{- end }} {{- /* Redis connection env vars */}} {{- if .Values.redis.enabled }} diff --git a/backend/src/build/build.service.ts b/backend/src/build/build.service.ts index 7e9d511..6defbb5 100644 --- a/backend/src/build/build.service.ts +++ b/backend/src/build/build.service.ts @@ -997,7 +997,7 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv private laravelDockerfile(app: Application): string { const phpVersion = app.phpVersion || '8.3'; - const port = app.port || 8000; + const port = app.port || 80; return `# --- Build stage --- FROM composer:2 AS composer WORKDIR /app @@ -1066,8 +1066,16 @@ RUN mkdir -p storage/logs storage/framework/cache storage/framework/sessions sto RUN php artisan config:cache && php artisan route:cache && php artisan view:cache || true RUN php artisan storage:link || true +RUN echo '#!/bin/sh' > /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'set -e' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'cd /var/www/html' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'mkdir -p storage/logs storage/framework/cache storage/framework/sessions storage/framework/views' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'chown -R www-data:www-data storage' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + echo 'exec /usr/bin/supervisord -c /etc/supervisord.conf' >> /usr/local/bin/cloudhost-laravel-entrypoint.sh && \\ + chmod +x /usr/local/bin/cloudhost-laravel-entrypoint.sh + EXPOSE ${port} -CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"] +CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"] `; } diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index 7f6b372..d8bc312 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -431,6 +431,16 @@ export class KubernetesService implements OnModuleInit { ); } + if ( + (ctx.runtime === AppRuntime.LARAVEL || ctx.runtime === AppRuntime.PHP) && + ctx.databaseType !== DatabaseType.NONE + ) { + extraEnv.push( + { name: 'DB_DATABASE', value: ctx.appName.replace(/-/g, '_') }, + { name: 'DB_USERNAME', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' } } }, + ); + } + // Redis connection env vars if (ctx.enableRedis) { const redisName = `${ctx.appName}-redis`; diff --git a/frontend/src/app/dashboard/deploy/page.tsx b/frontend/src/app/dashboard/deploy/page.tsx index abec13e..3159221 100644 --- a/frontend/src/app/dashboard/deploy/page.tsx +++ b/frontend/src/app/dashboard/deploy/page.tsx @@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { useMutation, useQuery } from '@tanstack/react-query'; import api from '@/lib/api'; +import { parseDotenv } from '@/lib/parseDotenv'; import { useAuthStore } from '@/lib/store'; import { toast } from 'react-toastify'; import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types'; @@ -58,6 +59,8 @@ export default function DeployPage() { }); const [envKey, setEnvKey] = useState(''); const [envVal, setEnvVal] = useState(''); + const [envFile, setEnvFile] = useState(null); + const envFileInputRef = useRef(null); const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload'); const [zipFile, setZipFile] = useState(null); const [uploadProgress, setUploadProgress] = useState(0); @@ -355,6 +358,29 @@ export default function DeployPage() { setForm({ ...form, envVars: updated }); }; + const handleEnvFileSelect = (file: File) => { + const reader = new FileReader(); + reader.onload = () => { + const content = typeof reader.result === 'string' ? reader.result : ''; + const { vars, errors } = parseDotenv(content); + const count = Object.keys(vars).length; + + if (count === 0) { + toast.error(errors.length > 0 ? errors[0] : 'No valid environment variables found in file'); + return; + } + + setForm((prev) => ({ ...prev, envVars: { ...prev.envVars, ...vars } })); + setEnvFile(file); + toast.success(`${count} environment variable${count === 1 ? '' : 's'} imported from ${file.name}`); + if (errors.length > 0) { + toast.warn(`${errors.length} line${errors.length === 1 ? '' : 's'} skipped`); + } + }; + reader.onerror = () => toast.error('Failed to read environment file'); + reader.readAsText(file); + }; + const handleSubmit = () => { const payload = { ...form }; // Format dbStorageSize with Gi suffix @@ -570,7 +596,7 @@ export default function DeployPage() { onClick={() => { const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' }; if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; } - else if (opt.value === 'laravel') { updates.port = 8000; updates.phpVersion = '8.3'; } + else if (opt.value === 'laravel') { updates.port = 80; updates.phpVersion = '8.3'; } else if (opt.value === 'wordpress') { updates.port = 80; updates.databaseType = 'mysql'; updates.runtimeVersion = '6.7'; updates.phpVersion = '8.3'; } else if (opt.value === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; } else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; } @@ -2015,6 +2041,46 @@ export default function DeployPage() { /> +
+ {envFile ? ( +
+
+ + {envFile.name} +
+ +
+ ) : ( + + )} + { + const file = e.target.files?.[0]; + if (file) handleEnvFileSelect(file); + if (envFileInputRef.current) envFileInputRef.current.value = ''; + }} + /> +
{Object.entries(form.envVars || {}).map(([key, value]) => (
diff --git a/frontend/src/lib/parseDotenv.ts b/frontend/src/lib/parseDotenv.ts new file mode 100644 index 0000000..aa16f39 --- /dev/null +++ b/frontend/src/lib/parseDotenv.ts @@ -0,0 +1,49 @@ +const KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/; + +function unquote(value: string): string { + const trimmed = value.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + const inner = trimmed.slice(1, -1); + if (trimmed.startsWith('"')) { + return inner.replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\'); + } + return inner; + } + return trimmed; +} + +export function parseDotenv(content: string): { + vars: Record; + errors: string[]; +} { + const vars: Record = {}; + const errors: string[] = []; + const lines = content.split(/\r?\n/); + + lines.forEach((line, index) => { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) return; + + const withoutExport = trimmed.startsWith('export ') ? trimmed.slice(7).trim() : trimmed; + const eqIndex = withoutExport.indexOf('='); + if (eqIndex === -1) { + errors.push(`Line ${index + 1}: missing '='`); + return; + } + + const key = withoutExport.slice(0, eqIndex).trim(); + const rawValue = withoutExport.slice(eqIndex + 1); + + if (!KEY_PATTERN.test(key)) { + errors.push(`Line ${index + 1}: invalid key "${key}"`); + return; + } + + vars[key] = unquote(rawValue); + }); + + return { vars, errors }; +}