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:
@@ -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}`,
|
||||
|
||||
Reference in New Issue
Block a user