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 <cursoragent@cursor.com>
This commit is contained in:
@@ -53,6 +53,15 @@ spec:
|
|||||||
key: password
|
key: password
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: "postgresql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:5432/{{ include "cloudhost-app.dbName" . }}"
|
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 }}
|
{{- end }}
|
||||||
{{- if and .Values.database.enabled (eq .Values.database.type "mysql") }}
|
{{- if and .Values.database.enabled (eq .Values.database.type "mysql") }}
|
||||||
- name: DB_HOST
|
- name: DB_HOST
|
||||||
@@ -73,6 +82,15 @@ spec:
|
|||||||
key: password
|
key: password
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: "mysql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:3306/{{ include "cloudhost-app.dbName" . }}"
|
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 }}
|
{{- end }}
|
||||||
{{- if and .Values.database.enabled (eq .Values.database.type "mariadb") }}
|
{{- if and .Values.database.enabled (eq .Values.database.type "mariadb") }}
|
||||||
- name: DB_HOST
|
- name: DB_HOST
|
||||||
@@ -93,6 +111,15 @@ spec:
|
|||||||
key: password
|
key: password
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: "mysql://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:3306/{{ include "cloudhost-app.dbName" . }}"
|
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 }}
|
{{- end }}
|
||||||
{{- if and .Values.database.enabled (eq .Values.database.type "mongodb") }}
|
{{- if and .Values.database.enabled (eq .Values.database.type "mongodb") }}
|
||||||
- name: DB_HOST
|
- 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"
|
value: "mongodb://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:27017/{{ include "cloudhost-app.dbName" . }}?authSource=admin"
|
||||||
- name: DATABASE_URL
|
- name: DATABASE_URL
|
||||||
value: "mongodb://$(DB_USER):$(DB_PASSWORD)@{{ include "cloudhost-app.dbDeploymentName" . }}:27017/{{ include "cloudhost-app.dbName" . }}?authSource=admin"
|
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 }}
|
{{- end }}
|
||||||
{{- /* Redis connection env vars */}}
|
{{- /* Redis connection env vars */}}
|
||||||
{{- if .Values.redis.enabled }}
|
{{- if .Values.redis.enabled }}
|
||||||
|
|||||||
@@ -997,7 +997,7 @@ CMD ["sh", "-c", "if [ \\"$(cat /app/.mode)\\" = \\"standalone\\" ] && [ -f serv
|
|||||||
|
|
||||||
private laravelDockerfile(app: Application): string {
|
private laravelDockerfile(app: Application): string {
|
||||||
const phpVersion = app.phpVersion || '8.3';
|
const phpVersion = app.phpVersion || '8.3';
|
||||||
const port = app.port || 8000;
|
const port = app.port || 80;
|
||||||
return `# --- Build stage ---
|
return `# --- Build stage ---
|
||||||
FROM composer:2 AS composer
|
FROM composer:2 AS composer
|
||||||
WORKDIR /app
|
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 config:cache && php artisan route:cache && php artisan view:cache || true
|
||||||
RUN php artisan storage:link || 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}
|
EXPOSE ${port}
|
||||||
CMD ["/usr/bin/supervisord", "-c", "/etc/supervisord.conf"]
|
CMD ["/usr/local/bin/cloudhost-laravel-entrypoint.sh"]
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// Redis connection env vars
|
||||||
if (ctx.enableRedis) {
|
if (ctx.enableRedis) {
|
||||||
const redisName = `${ctx.appName}-redis`;
|
const redisName = `${ctx.appName}-redis`;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useState, useRef, useCallback } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import api from '@/lib/api';
|
import api from '@/lib/api';
|
||||||
|
import { parseDotenv } from '@/lib/parseDotenv';
|
||||||
import { useAuthStore } from '@/lib/store';
|
import { useAuthStore } from '@/lib/store';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
|
import type { CreateApplicationDto, ClusterPublic, ClusterPoolPublic, CostBreakdown, BillingCycle } from '@/types';
|
||||||
@@ -58,6 +59,8 @@ export default function DeployPage() {
|
|||||||
});
|
});
|
||||||
const [envKey, setEnvKey] = useState('');
|
const [envKey, setEnvKey] = useState('');
|
||||||
const [envVal, setEnvVal] = useState('');
|
const [envVal, setEnvVal] = useState('');
|
||||||
|
const [envFile, setEnvFile] = useState<File | null>(null);
|
||||||
|
const envFileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
|
const [sourceMethod, setSourceMethod] = useState<'git' | 'upload'>('upload');
|
||||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||||
const [uploadProgress, setUploadProgress] = useState(0);
|
const [uploadProgress, setUploadProgress] = useState(0);
|
||||||
@@ -355,6 +358,29 @@ export default function DeployPage() {
|
|||||||
setForm({ ...form, envVars: updated });
|
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 handleSubmit = () => {
|
||||||
const payload = { ...form };
|
const payload = { ...form };
|
||||||
// Format dbStorageSize with Gi suffix
|
// Format dbStorageSize with Gi suffix
|
||||||
@@ -570,7 +596,7 @@ export default function DeployPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
|
const updates: any = { runtime: opt.value as any, phpVersion: '', runtimeVersion: '' };
|
||||||
if (opt.value === 'nodejs') { updates.port = 3000; updates.runtimeVersion = '20'; }
|
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 === '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 === 'go') { updates.port = 8080; updates.runtimeVersion = '1.22'; }
|
||||||
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
else if (opt.value === 'python') { updates.port = 8000; updates.runtimeVersion = '3.12'; }
|
||||||
@@ -2015,6 +2041,46 @@ export default function DeployPage() {
|
|||||||
/>
|
/>
|
||||||
<button type="button" onClick={addEnvVar} className="btn-secondary shrink-0">Add</button>
|
<button type="button" onClick={addEnvVar} className="btn-secondary shrink-0">Add</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="mb-3">
|
||||||
|
{envFile ? (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded-xl">
|
||||||
|
<div className="flex items-center space-x-2 min-w-0">
|
||||||
|
<CheckCircle className="w-4 h-4 text-green-600 shrink-0" />
|
||||||
|
<span className="text-sm text-green-800 truncate">{envFile.name}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setEnvFile(null);
|
||||||
|
if (envFileInputRef.current) envFileInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
className="text-sm text-red-500 hover:text-red-700 font-medium shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => envFileInputRef.current?.click()}
|
||||||
|
className="w-full flex items-center justify-center gap-2 p-3 border-2 border-dashed border-gray-300 rounded-xl text-sm text-gray-600 hover:border-primary-400 hover:text-primary-600 transition-colors"
|
||||||
|
>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
Upload .env file
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
ref={envFileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".env,.env.local,.env.production,.env.example"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) handleEnvFileSelect(file);
|
||||||
|
if (envFileInputRef.current) envFileInputRef.current.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{Object.entries(form.envVars || {}).map(([key, value]) => (
|
{Object.entries(form.envVars || {}).map(([key, value]) => (
|
||||||
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
|
<div key={key} className="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 mb-2">
|
||||||
<span className="text-sm font-mono">
|
<span className="text-sm font-mono">
|
||||||
|
|||||||
@@ -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<string, string>;
|
||||||
|
errors: string[];
|
||||||
|
} {
|
||||||
|
const vars: Record<string, string> = {};
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user