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
+46 -5
View File
@@ -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;