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
+78 -12
View File
@@ -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}`,