diff --git a/backend/helm/cloudhost-app/templates/ingress.yaml b/backend/helm/cloudhost-app/templates/ingress.yaml index 6a8650a..361bd32 100644 --- a/backend/helm/cloudhost-app/templates/ingress.yaml +++ b/backend/helm/cloudhost-app/templates/ingress.yaml @@ -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 }} diff --git a/backend/helm/cloudhost-app/values.yaml b/backend/helm/cloudhost-app/values.yaml index 8f84652..dfab77a 100644 --- a/backend/helm/cloudhost-app/values.yaml +++ b/backend/helm/cloudhost-app/values.yaml @@ -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 diff --git a/backend/src/applications/applications.controller.ts b/backend/src/applications/applications.controller.ts index 5585429..49a68ea 100644 --- a/backend/src/applications/applications.controller.ts +++ b/backend/src/applications/applications.controller.ts @@ -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') diff --git a/backend/src/config/configuration.ts b/backend/src/config/configuration.ts index e5124f8..3491f98 100644 --- a/backend/src/config/configuration.ts +++ b/backend/src/config/configuration.ts @@ -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', diff --git a/backend/src/deployments/deployments.service.ts b/backend/src/deployments/deployments.service.ts index cf13c01..9bb0df0 100644 --- a/backend/src/deployments/deployments.service.ts +++ b/backend/src/deployments/deployments.service.ts @@ -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 "" 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 { 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 { + private async executePipeline( + deploymentId: string, + app: any, + previewSubdomain: string | null, + ): Promise { 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 }> { 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); }); diff --git a/backend/src/deployments/entities/deployment.entity.ts b/backend/src/deployments/entities/deployment.entity.ts index 9a401e1..7c4cd74 100644 --- a/backend/src/deployments/entities/deployment.entity.ts +++ b/backend/src/deployments/entities/deployment.entity.ts @@ -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; diff --git a/backend/src/kubernetes/kubernetes.service.ts b/backend/src/kubernetes/kubernetes.service.ts index c4f9a99..18576fa 100644 --- a/backend/src/kubernetes/kubernetes.service.ts +++ b/backend/src/kubernetes/kubernetes.service.ts @@ -279,8 +279,18 @@ export class KubernetesService implements OnModuleInit { return values; } - private buildHelmValues(app: Application, imageUri: string): Record { + private buildHelmValues( + app: Application, + imageUri: string, + previewNumber?: string | null, + ): Record { const domain = this.configService.get('platform.domain'); + const previewRootDomain = this.configService.get('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> { + async deployApplication( + app: Application, + imageUri: string, + opts?: { previewNumber?: string | null }, + ): Promise> { 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> { + private async deployViaHelm( + app: Application, + imageUri: string, + previewNumber?: string | null, + ): Promise> { 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> { + private async deployViaK8sApi( + app: Application, + imageUri: string, + previewNumber?: string | null, + ): Promise> { 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 { + private async applyIngress( + networkingApi: k8s.NetworkingV1Api, + ctx: ManifestContext, + customDomain?: string, + previewNumber?: string | null, + ): Promise { 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('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('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}`, diff --git a/frontend/src/app/dashboard/apps/[id]/page.tsx b/frontend/src/app/dashboard/apps/[id]/page.tsx index e3b7aea..82e9507 100644 --- a/frontend/src/app/dashboard/apps/[id]/page.tsx +++ b/frontend/src/app/dashboard/apps/[id]/page.tsx @@ -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.'), });