Use per-deploy preview hosts under the site root domain.

Build hosts as <userPrefix>-<deploymentNumber>-preview.<rootDomain> from FRONTEND_URL, wire them through ingress/TLS, and open them from the preview API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-27 12:48:22 +03:30
parent 44ad1d63a0
commit be2587dcf5
8 changed files with 210 additions and 22 deletions
@@ -2,6 +2,7 @@
{{- $name := include "cloudhost-app.name" . -}} {{- $name := include "cloudhost-app.name" . -}}
{{- $ns := include "cloudhost-app.namespace" . -}} {{- $ns := include "cloudhost-app.namespace" . -}}
{{- $host := printf "%s.%s" (default $name .Values.ingress.subdomain) .Values.ingress.domain -}} {{- $host := printf "%s.%s" (default $name .Values.ingress.subdomain) .Values.ingress.domain -}}
{{- $previewHost := .Values.ingress.previewHost | default "" -}}
apiVersion: networking.k8s.io/v1 apiVersion: networking.k8s.io/v1
kind: Ingress kind: Ingress
metadata: metadata:
@@ -36,11 +37,26 @@ spec:
port: port:
number: 80 number: 80
{{- end }} {{- end }}
{{- if $previewHost }}
- host: {{ $previewHost }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ $name }}
port:
number: 80
{{- end }}
tls: tls:
- hosts: - hosts:
- {{ $host }} - {{ $host }}
{{- if .Values.ingress.customDomain }} {{- if .Values.ingress.customDomain }}
- {{ .Values.ingress.customDomain }} - {{ .Values.ingress.customDomain }}
{{- end }} {{- end }}
{{- if $previewHost }}
- {{ $previewHost }}
{{- end }}
secretName: {{ $name }}-tls secretName: {{ $name }}-tls
{{- end }} {{- end }}
+4
View File
@@ -39,6 +39,10 @@ ingress:
clusterIssuer: letsencrypt-prod clusterIssuer: letsencrypt-prod
customDomain: "" # Optional custom domain (e.g. "www.example.com") customDomain: "" # Optional custom domain (e.g. "www.example.com")
# Optional additional host for per-deployment preview (e.g. abc123.platform.example.com).
# When set, the ingress will route traffic for this host to the same service and include it in TLS SANs.
previewHost: ""
# ── Database (MySQL or PostgreSQL) ─────────────────────── # ── Database (MySQL or PostgreSQL) ───────────────────────
database: database:
enabled: false enabled: false
@@ -409,7 +409,10 @@ export class ApplicationsController {
@ApiOperation({ summary: 'Get preview URL for the deployed application' }) @ApiOperation({ summary: 'Get preview URL for the deployed application' })
async getPreview(@Param('id') id: string, @Request() req: any) { async getPreview(@Param('id') id: string, @Request() req: any) {
const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req)); const app = await this.applicationsService.findOne(id, this.staffUserIdFilter(req));
return this.kubernetesService.getPreviewInfo(app); const deployments = await this.deploymentsService.findByApplication(app.id);
const latest = deployments.find((d) => d.status === 'running') || deployments[0];
const previewNumber = latest?.previewSubdomain ?? null;
return this.kubernetesService.getPreviewInfo(app, previewNumber);
} }
@Post(':id/access') @Post(':id/access')
+48 -1
View File
@@ -1,3 +1,49 @@
function resolvePlatformDomainFromEnv(): string {
const frontendUrl = process.env.FRONTEND_URL;
if (frontendUrl) {
try {
const url = new URL(frontendUrl);
if (url.hostname && !/^\d+\.\d+\.\d+\.\d+$/.test(url.hostname)) {
return url.hostname;
}
} catch {
// ignore parse errors and fall back to PLATFORM_DOMAIN
}
}
return process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local';
}
function stripLeadingSubdomain(hostname: string): string {
const h = (hostname || '').toLowerCase();
if (!h) return h;
// If it looks like an IP (IPv4 or IPv6), don't try to strip labels.
if (h.includes(':') || /^\d+\.\d+\.\d+\.\d+$/.test(h)) {
return h;
}
const parts = h.split('.').filter(Boolean);
if (parts.length >= 3) {
return parts.slice(1).join('.');
}
return h;
}
function resolvePreviewRootDomainFromEnv(): string {
const frontendUrl = process.env.FRONTEND_URL;
if (frontendUrl) {
try {
const url = new URL(frontendUrl);
if (url.hostname) {
return stripLeadingSubdomain(url.hostname);
}
} catch {
// ignore and fall back
}
}
// Fallback: strip from PLATFORM_DOMAIN if it's already a subdomain host.
return stripLeadingSubdomain(process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local');
}
export default () => ({ export default () => ({
nodeEnv: process.env.NODE_ENV || 'development', nodeEnv: process.env.NODE_ENV || 'development',
port: parseInt(process.env.PORT || '4000', 10), port: parseInt(process.env.PORT || '4000', 10),
@@ -60,7 +106,8 @@ export default () => ({
}, },
platform: { platform: {
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local', domain: resolvePlatformDomainFromEnv(),
previewRootDomain: resolvePreviewRootDomainFromEnv(),
uploadDir: process.env.UPLOAD_DIR || './uploads', uploadDir: process.env.UPLOAD_DIR || './uploads',
/** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */ /** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */
storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable', storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable',
+46 -5
View File
@@ -6,6 +6,7 @@ import { Deployment } from './entities/deployment.entity';
import { ApplicationsService } from '../applications/applications.service'; import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service'; import { KubernetesService } from '../kubernetes/kubernetes.service';
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service'; import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
import * as crypto from 'crypto';
import { import {
AppLifecycleStatus, AppLifecycleStatus,
DeploymentStatus, DeploymentStatus,
@@ -28,6 +29,16 @@ export class DeploymentsService {
private clustersService: ClustersService, private clustersService: ClustersService,
) {} ) {}
/**
* Stable "number" part for preview host, derived from deployment id.
* We keep it numeric to match the "<number>" requirement.
*/
private computePreviewNumberFromDeploymentId(deploymentId: string): string {
const hashHex = crypto.createHash('sha256').update(deploymentId).digest('hex');
const num = parseInt(hashHex.slice(0, 8), 16) % 1_000_000; // 0..999999
return String(num).padStart(6, '0');
}
async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> { async triggerDeployment(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId); const app = await this.applicationsService.findOne(applicationId, userId);
@@ -38,13 +49,22 @@ export class DeploymentsService {
imageTag: `${app.name}:${Date.now()}`, imageTag: `${app.name}:${Date.now()}`,
status: DeploymentStatus.PENDING, status: DeploymentStatus.PENDING,
version: `v${Date.now()}`, version: `v${Date.now()}`,
previewSubdomain: null,
}); });
const saved = await this.deploymentsRepository.save(deployment); const saved = await this.deploymentsRepository.save(deployment);
// Fill deterministic preview number after we have the deployment id.
let previewSubdomain: string | null = null;
if (!app.customDomain) {
previewSubdomain = this.computePreviewNumberFromDeploymentId(saved.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain;
}
// Trigger async pipeline (Helm-only for managed services, build+deploy for apps) // Trigger async pipeline (Helm-only for managed services, build+deploy for apps)
const run = isManagedProductType(app.productType) const run = isManagedProductType(app.productType)
? this.executeManagedPipeline(saved.id, app) ? this.executeManagedPipeline(saved.id, app)
: this.executePipeline(saved.id, app); : this.executePipeline(saved.id, app, previewSubdomain);
run.catch((error) => { run.catch((error) => {
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error); this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
}); });
@@ -137,7 +157,11 @@ export class DeploymentsService {
} }
} }
private async executePipeline(deploymentId: string, app: any): Promise<void> { private async executePipeline(
deploymentId: string,
app: any,
previewSubdomain: string | null,
): Promise<void> {
try { try {
// Step 1: Build image // Step 1: Build image
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING); await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
@@ -159,7 +183,13 @@ export class DeploymentsService {
}); });
const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath); const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath);
const { app: deployedApp, k8sResources } = await this.deployWithClusterFallback(deploymentId, app, imageUri, hasDbDump); const { app: deployedApp, k8sResources } = await this.deployWithClusterFallback(
deploymentId,
app,
imageUri,
hasDbDump,
previewSubdomain,
);
app = deployedApp; app = deployedApp;
// Step 3.5: Restore DB dump if one was uploaded (must happen after deploy creates the namespace + DB) // Step 3.5: Restore DB dump if one was uploaded (must happen after deploy creates the namespace + DB)
@@ -316,6 +346,7 @@ export class DeploymentsService {
app: any, app: any,
imageUri: string, imageUri: string,
hasDbDump: boolean, hasDbDump: boolean,
previewSubdomain: string | null,
): Promise<{ app: any; k8sResources: Record<string, any> }> { ): Promise<{ app: any; k8sResources: Record<string, any> }> {
const failedClusterIds: string[] = []; const failedClusterIds: string[] = [];
let currentApp = app; let currentApp = app;
@@ -340,7 +371,9 @@ export class DeploymentsService {
hasDbDump && !isManagedProductType(currentApp.productType) hasDbDump && !isManagedProductType(currentApp.productType)
? { ...currentApp, replicas: 0 } ? { ...currentApp, replicas: 0 }
: currentApp; : currentApp;
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri); const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri, {
previewNumber: previewSubdomain,
});
return { app: currentApp, k8sResources }; return { app: currentApp, k8sResources };
} catch (error: any) { } catch (error: any) {
lastError = error; lastError = error;
@@ -618,11 +651,19 @@ export class DeploymentsService {
imageTag: `${app.name}:${Date.now()}`, imageTag: `${app.name}:${Date.now()}`,
status: DeploymentStatus.PENDING, status: DeploymentStatus.PENDING,
version: `v${Date.now()}`, version: `v${Date.now()}`,
previewSubdomain: null,
}); });
const saved = await this.deploymentsRepository.save(deployment); const saved = await this.deploymentsRepository.save(deployment);
let previewSubdomain: string | null = null;
if (!app.customDomain) {
previewSubdomain = this.computePreviewNumberFromDeploymentId(saved.id);
await this.deploymentsRepository.update(saved.id, { previewSubdomain });
saved.previewSubdomain = previewSubdomain;
}
// Trigger async build & deploy pipeline (same as initial deploy) // Trigger async build & deploy pipeline (same as initial deploy)
this.executePipeline(saved.id, app).catch((error) => { this.executePipeline(saved.id, app, previewSubdomain).catch((error) => {
this.logger.error(`Redeploy pipeline failed for deployment ${saved.id}:`, error); this.logger.error(`Redeploy pipeline failed for deployment ${saved.id}:`, error);
}); });
@@ -33,6 +33,13 @@ export class Deployment {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
deployLog: string; deployLog: string;
/**
* Per-deployment preview number (derived deterministically from deployment.id).
* Used to build preview ingress host under the main frontend domain.
*/
@Column({ type: 'varchar', length: 63, nullable: true })
previewSubdomain: string | null;
@Column({ nullable: true }) @Column({ nullable: true })
errorMessage: string; errorMessage: string;
+78 -12
View File
@@ -279,8 +279,18 @@ export class KubernetesService implements OnModuleInit {
return values; return values;
} }
private buildHelmValues(app: Application, imageUri: string): Record<string, any> { private buildHelmValues(
app: Application,
imageUri: string,
previewNumber?: string | null,
): Record<string, any> {
const domain = this.configService.get('platform.domain'); const domain = this.configService.get('platform.domain');
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0];
const previewHost =
previewNumber && !app.customDomain
? `${namespacePrefix}-${previewNumber}-preview.${previewRootDomain}`
: '';
const pullRegistryUrl = this.registryService.getRegistryUrl(); const pullRegistryUrl = this.registryService.getRegistryUrl();
const isWordPress = app.runtime === AppRuntime.WORDPRESS; const isWordPress = app.runtime === AppRuntime.WORDPRESS;
const hasDb = app.databaseType !== DatabaseType.NONE; const hasDb = app.databaseType !== DatabaseType.NONE;
@@ -312,6 +322,7 @@ export class KubernetesService implements OnModuleInit {
clusterIssuer: 'letsencrypt-prod', clusterIssuer: 'letsencrypt-prod',
customDomain: customDomain:
app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '', app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '',
previewHost,
}, },
registry: { registry: {
url: pullRegistryUrl, url: pullRegistryUrl,
@@ -365,11 +376,16 @@ export class KubernetesService implements OnModuleInit {
} }
} }
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> { async deployApplication(
app: Application,
imageUri: string,
opts?: { previewNumber?: string | null },
): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) { if (isManagedProductType(app.productType)) {
return this.deployManagedService(app); return this.deployManagedService(app);
} }
const previewNumber = opts?.previewNumber ?? null;
const workloadImage = this.registryService.normalizeImageReference(imageUri); const workloadImage = this.registryService.normalizeImageReference(imageUri);
if (workloadImage !== imageUri) { if (workloadImage !== imageUri) {
this.logger.log(`Using in-cluster registry image for ${app.name}: ${workloadImage}`); this.logger.log(`Using in-cluster registry image for ${app.name}: ${workloadImage}`);
@@ -377,12 +393,12 @@ export class KubernetesService implements OnModuleInit {
// Try Helm first, fall back to direct K8s API if Helm is unavailable // Try Helm first, fall back to direct K8s API if Helm is unavailable
try { try {
return await this.deployViaHelm(app, workloadImage); return await this.deployViaHelm(app, workloadImage, previewNumber);
} catch (helmError: any) { } catch (helmError: any) {
this.logger.warn( this.logger.warn(
`Helm deploy failed for ${app.name}, falling back to direct K8s API: ${helmError.message}`, `Helm deploy failed for ${app.name}, falling back to direct K8s API: ${helmError.message}`,
); );
return await this.deployViaK8sApi(app, workloadImage); return await this.deployViaK8sApi(app, workloadImage, previewNumber);
} }
} }
@@ -445,6 +461,7 @@ export class KubernetesService implements OnModuleInit {
? this.registryService.normalizeImageReference(app.latestImageTag) ? this.registryService.normalizeImageReference(app.latestImageTag)
: ''; : '';
const values = this.buildHelmValues(app, imageUri); const values = this.buildHelmValues(app, imageUri);
// Preview host is managed per-deployment; Helm chart only needs the main ingress host here.
await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig); await this.helmService.installOrUpgrade(app.name, namespace, values, kubeconfig);
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`); this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
} catch (helmError: any) { } catch (helmError: any) {
@@ -487,11 +504,15 @@ export class KubernetesService implements OnModuleInit {
// ── Helm-based deployment ───────────────────────────────────────── // ── Helm-based deployment ─────────────────────────────────────────
private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> { private async deployViaHelm(
app: Application,
imageUri: string,
previewNumber?: string | null,
): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId); const kubeconfig = await this.getKubeconfig(app.clusterId);
await this.ensurePlatformStorageClass(kubeconfig); await this.ensurePlatformStorageClass(kubeconfig);
const { coreApi } = await this.getK8sClient(app.clusterId); const { coreApi } = await this.getK8sClient(app.clusterId);
const values = this.buildHelmValues(app, imageUri); const values = this.buildHelmValues(app, imageUri, previewNumber);
const namespace = values.app.namespace as string; const namespace = values.app.namespace as string;
await this.registryService.ensureRegistryPullSecret(coreApi, namespace); await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
const releaseName = app.name; const releaseName = app.name;
@@ -590,7 +611,11 @@ export class KubernetesService implements OnModuleInit {
return manifests; return manifests;
} }
private async deployViaK8sApi(app: Application, imageUri: string): Promise<Record<string, any>> { private async deployViaK8sApi(
app: Application,
imageUri: string,
previewNumber?: string | null,
): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) { if (isManagedProductType(app.productType)) {
return this.deployManagedViaK8sApi(app); return this.deployManagedViaK8sApi(app);
} }
@@ -678,7 +703,7 @@ export class KubernetesService implements OnModuleInit {
// 6. Create Ingress // 6. Create Ingress
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED) const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain : undefined; ? app.customDomain : undefined;
manifests.ingress = await this.applyIngress(networkingApi, context, customDomain); manifests.ingress = await this.applyIngress(networkingApi, context, customDomain, previewNumber);
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`); this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`);
} catch (error: any) { } catch (error: any) {
@@ -1356,7 +1381,12 @@ export class KubernetesService implements OnModuleInit {
return service; return service;
} }
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext, customDomain?: string): Promise<any> { private async applyIngress(
networkingApi: k8s.NetworkingV1Api,
ctx: ManifestContext,
customDomain?: string,
previewNumber?: string | null,
): Promise<any> {
const host = `${ctx.subdomain}.${ctx.domain}`; const host = `${ctx.subdomain}.${ctx.domain}`;
const rules: k8s.V1IngressRule[] = [ const rules: k8s.V1IngressRule[] = [
{ {
@@ -1390,6 +1420,28 @@ export class KubernetesService implements OnModuleInit {
tlsHosts.push(customDomain); tlsHosts.push(customDomain);
} }
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || ctx.domain;
const namespacePrefix = ctx.ownerId.split('-')[0];
const previewHost =
previewNumber && !customDomain
? `${namespacePrefix}-${previewNumber}-preview.${previewRootDomain}`
: '';
if (previewHost) {
rules.push({
host: previewHost,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
});
tlsHosts.push(previewHost);
}
const ingress: k8s.V1Ingress = { const ingress: k8s.V1Ingress = {
apiVersion: 'networking.k8s.io/v1', apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress', kind: 'Ingress',
@@ -2659,7 +2711,10 @@ export class KubernetesService implements OnModuleInit {
* Get preview info for a deployed application. * Get preview info for a deployed application.
* Patches the service to NodePort if needed, and returns the access URL. * Patches the service to NodePort if needed, and returns the access URL.
*/ */
async getPreviewInfo(app: Application): Promise<{ async getPreviewInfo(
app: Application,
previewNumber?: string | null,
): Promise<{
url: string; url: string;
nodePort: number; nodePort: number;
host: string; host: string;
@@ -2713,9 +2768,20 @@ export class KubernetesService implements OnModuleInit {
throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`); throw new Error(`Service not found for "${app.name}". Make sure the app is deployed.`);
} }
// Build ingress URL // Build ingress URL (main / custom domain / preview host)
const subdomain = app.subdomain || app.name; const subdomain = app.subdomain || app.name;
const ingressUrl = `https://${subdomain}.${domain}`; const verifiedCustomDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain
: null;
const previewRootDomain = this.configService.get<string>('platform.previewRootDomain') || domain;
const namespacePrefix = app.userId.split('-')[0];
let ingressUrl = `https://${subdomain}.${domain}`;
if (verifiedCustomDomain) {
ingressUrl = `https://${verifiedCustomDomain}`;
} else if (previewNumber) {
ingressUrl = `https://${namespacePrefix}-${previewNumber}-preview.${previewRootDomain}`;
}
return { return {
url: `http://${hostIp}:${nodePort}`, url: `http://${hostIp}:${nodePort}`,
@@ -867,9 +867,13 @@ export default function AppDetailPage() {
const previewMutation = useMutation({ const previewMutation = useMutation({
mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data), mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data),
onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => { onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => {
// Open the preview URL in a new tab const targetUrl = data.ingressUrl || data.url;
window.open(data.url, '_blank'); window.open(targetUrl, '_blank');
if (data.ingressUrl) {
toast.success('Preview opened on HTTPS preview domain.');
} else {
toast.success(`Preview opened on port ${data.nodePort}`); toast.success(`Preview opened on port ${data.nodePort}`);
}
}, },
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'), onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
}); });