feat: add custom domain support with SSL, DNS verification, and billing

Users can assign a custom domain to their app with automatic SSL via
cert-manager. Includes DNS verification flow (CNAME check), Persian
instructions, admin-configurable pricing via PlatformSetting, and
integration into the deploy wizard cost calculation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-14 00:36:29 +03:30
parent d87b50c6a4
commit 435cf92817
18 changed files with 1082 additions and 89 deletions
+93 -18
View File
@@ -8,7 +8,7 @@ import { promisify } from 'util';
import { PassThrough } from 'stream';
import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity';
import { AppRuntime, DatabaseType } from '../common/enums';
import { AppRuntime, DatabaseType, CustomDomainStatus } from '../common/enums';
import { HelmService } from './helm.service';
const execFileAsync = promisify(execFile);
@@ -118,6 +118,9 @@ export class KubernetesService implements OnModuleInit {
subdomain: app.subdomain || app.name,
domain: domain,
clusterIssuer: 'letsencrypt-prod',
customDomain: (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain
: '',
},
registry: {
url: pullRegistryUrl,
@@ -182,6 +185,57 @@ export class KubernetesService implements OnModuleInit {
}
}
async updateIngress(app: Application): Promise<void> {
const domain = this.configService.get('platform.domain');
const subdomain = app.subdomain || app.name;
const namespace = `user-${app.userId.split('-')[0]}`;
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain : undefined;
try {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const imageUri = app.latestImageTag
? `${this.configService.get<string>('registry.pullUrl') || 'localhost:30500'}/${app.name}:${app.latestImageTag}`
: '';
const values = this.buildHelmValues(app, imageUri);
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) {
this.logger.warn(`Helm ingress update failed for ${app.name}, using direct K8s API: ${helmError.message}`);
const { networkingApi } = await this.getK8sClient(app.clusterId);
const ctx: ManifestContext = {
appName: app.name,
namespace,
image: '',
port: app.port,
replicas: app.replicas,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: app.envVars || {},
runtime: app.runtime,
databaseType: app.databaseType,
domain,
subdomain,
dbUsername: app.dbUsername || '',
dbPassword: app.dbPassword || '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: app.enableRedis || false,
redisVersion: app.redisVersion || '7.2',
enableRabbitmq: app.enableRabbitmq || false,
rabbitmqVersion: app.rabbitmqVersion || '3.13',
enableElasticsearch: app.enableElasticsearch || false,
elasticsearchVersion: app.elasticsearchVersion || '8.12',
logPaths: app.logPaths || [],
};
await this.applyIngress(networkingApi, ctx, customDomain);
this.logger.log(`Updated ingress for ${app.name} via K8s API (customDomain: ${customDomain || 'none'})`);
}
}
// ── Helm-based deployment ─────────────────────────────────────────
private async deployViaHelm(app: Application, imageUri: string): Promise<Record<string, any>> {
@@ -278,7 +332,9 @@ export class KubernetesService implements OnModuleInit {
manifests.service = await this.applyService(coreApi, context);
// 6. Create Ingress
manifests.ingress = await this.applyIngress(networkingApi, context);
const customDomain = (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain : undefined;
manifests.ingress = await this.applyIngress(networkingApi, context, customDomain);
this.logger.log(`Successfully deployed ${app.name} to namespace ${context.namespace} via K8s API`);
} catch (error: any) {
@@ -718,8 +774,40 @@ export class KubernetesService implements OnModuleInit {
return service;
}
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext): Promise<any> {
private async applyIngress(networkingApi: k8s.NetworkingV1Api, ctx: ManifestContext, customDomain?: string): Promise<any> {
const host = `${ctx.subdomain}.${ctx.domain}`;
const rules: k8s.V1IngressRule[] = [
{
host,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
},
];
const tlsHosts = [host];
if (customDomain) {
rules.push({
host: customDomain,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
});
tlsHosts.push(customDomain);
}
const ingress: k8s.V1Ingress = {
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
@@ -732,21 +820,8 @@ export class KubernetesService implements OnModuleInit {
},
spec: {
ingressClassName: 'nginx',
rules: [
{
host,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: { service: { name: ctx.appName, port: { number: 80 } } },
},
],
},
},
],
tls: [{ hosts: [host], secretName: `${ctx.appName}-tls` }],
rules,
tls: [{ hosts: tlsHosts, secretName: `${ctx.appName}-tls` }],
},
};