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" . -}}
|
{{- $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 }}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
toast.success(`Preview opened on port ${data.nodePort}`);
|
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.'),
|
onError: () => toast.error('Failed to get preview URL. Make sure the app is deployed.'),
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user