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:
@@ -2,6 +2,7 @@
|
||||
{{- $name := include "cloudhost-app.name" . -}}
|
||||
{{- $ns := include "cloudhost-app.namespace" . -}}
|
||||
{{- $host := printf "%s.%s" (default $name .Values.ingress.subdomain) .Values.ingress.domain -}}
|
||||
{{- $previewHost := .Values.ingress.previewHost | default "" -}}
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
@@ -36,11 +37,26 @@ spec:
|
||||
port:
|
||||
number: 80
|
||||
{{- end }}
|
||||
{{- if $previewHost }}
|
||||
- host: {{ $previewHost }}
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: {{ $name }}
|
||||
port:
|
||||
number: 80
|
||||
{{- end }}
|
||||
tls:
|
||||
- hosts:
|
||||
- {{ $host }}
|
||||
{{- if .Values.ingress.customDomain }}
|
||||
- {{ .Values.ingress.customDomain }}
|
||||
{{- end }}
|
||||
{{- if $previewHost }}
|
||||
- {{ $previewHost }}
|
||||
{{- end }}
|
||||
secretName: {{ $name }}-tls
|
||||
{{- end }}
|
||||
|
||||
@@ -39,6 +39,10 @@ ingress:
|
||||
clusterIssuer: letsencrypt-prod
|
||||
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:
|
||||
enabled: false
|
||||
|
||||
@@ -409,7 +409,10 @@ export class ApplicationsController {
|
||||
@ApiOperation({ summary: 'Get preview URL for the deployed application' })
|
||||
async getPreview(@Param('id') id: string, @Request() req: any) {
|
||||
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')
|
||||
|
||||
@@ -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 () => ({
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
port: parseInt(process.env.PORT || '4000', 10),
|
||||
@@ -60,7 +106,8 @@ export default () => ({
|
||||
},
|
||||
|
||||
platform: {
|
||||
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
|
||||
domain: resolvePlatformDomainFromEnv(),
|
||||
previewRootDomain: resolvePreviewRootDomainFromEnv(),
|
||||
uploadDir: process.env.UPLOAD_DIR || './uploads',
|
||||
/** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */
|
||||
storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable',
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Deployment } from './entities/deployment.entity';
|
||||
import { ApplicationsService } from '../applications/applications.service';
|
||||
import { KubernetesService } from '../kubernetes/kubernetes.service';
|
||||
import { BuildService, BuildProgress, BuildCancelledError } from '../build/build.service';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
AppLifecycleStatus,
|
||||
DeploymentStatus,
|
||||
@@ -28,6 +29,16 @@ export class DeploymentsService {
|
||||
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> {
|
||||
const app = await this.applicationsService.findOne(applicationId, userId);
|
||||
|
||||
@@ -38,13 +49,22 @@ export class DeploymentsService {
|
||||
imageTag: `${app.name}:${Date.now()}`,
|
||||
status: DeploymentStatus.PENDING,
|
||||
version: `v${Date.now()}`,
|
||||
previewSubdomain: null,
|
||||
});
|
||||
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)
|
||||
const run = isManagedProductType(app.productType)
|
||||
? this.executeManagedPipeline(saved.id, app)
|
||||
: this.executePipeline(saved.id, app);
|
||||
: this.executePipeline(saved.id, app, previewSubdomain);
|
||||
run.catch((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 {
|
||||
// Step 1: Build image
|
||||
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
|
||||
@@ -159,7 +183,13 @@ export class DeploymentsService {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
// 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,
|
||||
imageUri: string,
|
||||
hasDbDump: boolean,
|
||||
previewSubdomain: string | null,
|
||||
): Promise<{ app: any; k8sResources: Record<string, any> }> {
|
||||
const failedClusterIds: string[] = [];
|
||||
let currentApp = app;
|
||||
@@ -340,7 +371,9 @@ export class DeploymentsService {
|
||||
hasDbDump && !isManagedProductType(currentApp.productType)
|
||||
? { ...currentApp, replicas: 0 }
|
||||
: currentApp;
|
||||
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri);
|
||||
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri, {
|
||||
previewNumber: previewSubdomain,
|
||||
});
|
||||
return { app: currentApp, k8sResources };
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
@@ -618,11 +651,19 @@ export class DeploymentsService {
|
||||
imageTag: `${app.name}:${Date.now()}`,
|
||||
status: DeploymentStatus.PENDING,
|
||||
version: `v${Date.now()}`,
|
||||
previewSubdomain: null,
|
||||
});
|
||||
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)
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
@@ -33,6 +33,13 @@ export class Deployment {
|
||||
@Column({ type: 'text', nullable: true })
|
||||
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 })
|
||||
errorMessage: string;
|
||||
|
||||
|
||||
@@ -279,8 +279,18 @@ export class KubernetesService implements OnModuleInit {
|
||||
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 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 isWordPress = app.runtime === AppRuntime.WORDPRESS;
|
||||
const hasDb = app.databaseType !== DatabaseType.NONE;
|
||||
@@ -312,6 +322,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
clusterIssuer: 'letsencrypt-prod',
|
||||
customDomain:
|
||||
app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '',
|
||||
previewHost,
|
||||
},
|
||||
registry: {
|
||||
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)) {
|
||||
return this.deployManagedService(app);
|
||||
}
|
||||
|
||||
const previewNumber = opts?.previewNumber ?? null;
|
||||
const workloadImage = this.registryService.normalizeImageReference(imageUri);
|
||||
if (workloadImage !== imageUri) {
|
||||
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 {
|
||||
return await this.deployViaHelm(app, workloadImage);
|
||||
return await this.deployViaHelm(app, workloadImage, previewNumber);
|
||||
} catch (helmError: any) {
|
||||
this.logger.warn(
|
||||
`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)
|
||||
: '';
|
||||
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);
|
||||
this.logger.log(`Updated ingress for ${app.name} via Helm (customDomain: ${customDomain || 'none'})`);
|
||||
} catch (helmError: any) {
|
||||
@@ -487,11 +504,15 @@ export class KubernetesService implements OnModuleInit {
|
||||
|
||||
// ── 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);
|
||||
await this.ensurePlatformStorageClass(kubeconfig);
|
||||
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;
|
||||
await this.registryService.ensureRegistryPullSecret(coreApi, namespace);
|
||||
const releaseName = app.name;
|
||||
@@ -590,7 +611,11 @@ export class KubernetesService implements OnModuleInit {
|
||||
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)) {
|
||||
return this.deployManagedViaK8sApi(app);
|
||||
}
|
||||
@@ -678,7 +703,7 @@ export class KubernetesService implements OnModuleInit {
|
||||
// 6. Create Ingress
|
||||
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
|
||||
? 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`);
|
||||
} catch (error: any) {
|
||||
@@ -1356,7 +1381,12 @@ export class KubernetesService implements OnModuleInit {
|
||||
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 rules: k8s.V1IngressRule[] = [
|
||||
{
|
||||
@@ -1390,6 +1420,28 @@ export class KubernetesService implements OnModuleInit {
|
||||
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 = {
|
||||
apiVersion: 'networking.k8s.io/v1',
|
||||
kind: 'Ingress',
|
||||
@@ -2659,7 +2711,10 @@ export class KubernetesService implements OnModuleInit {
|
||||
* Get preview info for a deployed application.
|
||||
* 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;
|
||||
nodePort: number;
|
||||
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.`);
|
||||
}
|
||||
|
||||
// Build ingress URL
|
||||
// Build ingress URL (main / custom domain / preview host)
|
||||
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 {
|
||||
url: `http://${hostIp}:${nodePort}`,
|
||||
|
||||
@@ -867,9 +867,13 @@ export default function AppDetailPage() {
|
||||
const previewMutation = useMutation({
|
||||
mutationFn: () => api.get(`/applications/${appId}/preview`).then((r) => r.data),
|
||||
onSuccess: (data: { url: string; nodePort: number; host: string; ingressUrl?: string }) => {
|
||||
// Open the preview URL in a new tab
|
||||
window.open(data.url, '_blank');
|
||||
toast.success(`Preview opened on port ${data.nodePort}`);
|
||||
const targetUrl = data.ingressUrl || data.url;
|
||||
window.open(targetUrl, '_blank');
|
||||
if (data.ingressUrl) {
|
||||
toast.success('Preview opened on HTTPS preview domain.');
|
||||
} else {
|
||||
toast.success(`Preview opened on port ${data.nodePort}`);
|
||||
}
|
||||
},
|
||||
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user