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:
@@ -20,7 +20,8 @@ import { AuthGuard } from '@nestjs/passport';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags, ApiOperation, ApiBearerAuth, ApiConsumes } from '@nestjs/swagger';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto } from './dto/application.dto';
|
||||
import { DomainService } from './domain.service';
|
||||
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto, SetCustomDomainDto } from './dto/application.dto';
|
||||
import { RolesGuard } from '../common/guards/roles.guard';
|
||||
import { Roles } from '../common/decorators/roles.decorator';
|
||||
import { UserRole, DatabaseType } from '../common/enums';
|
||||
@@ -36,6 +37,7 @@ export class ApplicationsController {
|
||||
|
||||
constructor(
|
||||
private readonly applicationsService: ApplicationsService,
|
||||
private readonly domainService: DomainService,
|
||||
private readonly kubernetesService: KubernetesService,
|
||||
@Inject(forwardRef(() => DeploymentsService))
|
||||
private readonly deploymentsService: DeploymentsService,
|
||||
@@ -263,6 +265,69 @@ export class ApplicationsController {
|
||||
return this.kubernetesService.getPreviewInfo(app);
|
||||
}
|
||||
|
||||
// ── Custom Domain ─────────────────────────────────────────────
|
||||
|
||||
@Get(':id/domain')
|
||||
@ApiOperation({ summary: 'Get custom domain info and DNS setup instructions' })
|
||||
async getDomainInfo(@Param('id') id: string, @Request() req: any) {
|
||||
const userId = (req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL)
|
||||
? (await this.applicationsService.findOne(id)).userId
|
||||
: req.user.id;
|
||||
return this.domainService.getDomainInfo(id, userId);
|
||||
}
|
||||
|
||||
@Post(':id/domain')
|
||||
@ApiOperation({ summary: 'Set a custom domain for the application' })
|
||||
async setCustomDomain(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
@Body() dto: SetCustomDomainDto,
|
||||
) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const userId = isStaff
|
||||
? (await this.applicationsService.findOne(id)).userId
|
||||
: req.user.id;
|
||||
return this.domainService.setCustomDomain(id, userId, dto.domain);
|
||||
}
|
||||
|
||||
@Post(':id/domain/verify')
|
||||
@ApiOperation({ summary: 'Verify DNS records for the custom domain' })
|
||||
async verifyDomainDns(@Param('id') id: string, @Request() req: any) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const userId = isStaff
|
||||
? (await this.applicationsService.findOne(id)).userId
|
||||
: req.user.id;
|
||||
const result = await this.domainService.verifyDns(id, userId);
|
||||
|
||||
if (result.verified && result.application) {
|
||||
try {
|
||||
await this.kubernetesService.updateIngress(result.application);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to update ingress for ${id} after DNS verification: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/domain')
|
||||
@ApiOperation({ summary: 'Remove custom domain from the application' })
|
||||
async removeCustomDomain(@Param('id') id: string, @Request() req: any) {
|
||||
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
|
||||
const userId = isStaff
|
||||
? (await this.applicationsService.findOne(id)).userId
|
||||
: req.user.id;
|
||||
const app = await this.domainService.removeCustomDomain(id, userId);
|
||||
|
||||
try {
|
||||
await this.kubernetesService.updateIngress(app);
|
||||
} catch (e: any) {
|
||||
this.logger.warn(`Failed to update ingress for ${id} after domain removal: ${e.message}`);
|
||||
}
|
||||
|
||||
return { message: 'Custom domain removed successfully' };
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@ApiOperation({ summary: 'Delete an application and all its resources' })
|
||||
async delete(@Param('id') id: string, @Request() req: any) {
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ApplicationsService } from './applications.service';
|
||||
import { DomainService } from './domain.service';
|
||||
import { ApplicationsController } from './applications.controller';
|
||||
import { Application } from './entities/application.entity';
|
||||
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||
import { ClustersModule } from '../clusters/clusters.module';
|
||||
import { KubernetesModule } from '../kubernetes/kubernetes.module';
|
||||
import { DeploymentsModule } from '../deployments/deployments.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Application]),
|
||||
TypeOrmModule.forFeature([Application, PlatformSetting]),
|
||||
ClustersModule,
|
||||
KubernetesModule,
|
||||
forwardRef(() => DeploymentsModule),
|
||||
],
|
||||
controllers: [ApplicationsController],
|
||||
providers: [ApplicationsService],
|
||||
exports: [ApplicationsService],
|
||||
providers: [ApplicationsService, DomainService],
|
||||
exports: [ApplicationsService, DomainService],
|
||||
})
|
||||
export class ApplicationsModule {}
|
||||
|
||||
@@ -8,7 +8,7 @@ import * as crypto from 'crypto';
|
||||
import { Application } from './entities/application.entity';
|
||||
import { CreateApplicationDto, UpdateApplicationDto } from './dto/application.dto';
|
||||
import { ClustersService } from '../clusters/clusters.service';
|
||||
import { UserRole, DatabaseType } from '../common/enums';
|
||||
import { UserRole, DatabaseType, CustomDomainStatus } from '../common/enums';
|
||||
|
||||
@Injectable()
|
||||
export class ApplicationsService {
|
||||
@@ -76,6 +76,8 @@ export class ApplicationsService {
|
||||
this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`);
|
||||
}
|
||||
|
||||
const customDomain = dto.customDomain?.toLowerCase().trim() || undefined;
|
||||
|
||||
const app = this.appsRepository.create({
|
||||
...dto,
|
||||
userId,
|
||||
@@ -84,6 +86,8 @@ export class ApplicationsService {
|
||||
dbUsername,
|
||||
dbPassword,
|
||||
subdomain: `${dto.name}-${userId.split('-')[0]}`,
|
||||
customDomain: customDomain || undefined,
|
||||
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
|
||||
});
|
||||
return this.appsRepository.save(app);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
BadRequestException,
|
||||
NotFoundException,
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as dns from 'dns';
|
||||
import { Application } from './entities/application.entity';
|
||||
import { PlatformSetting } from '../billing/entities/platform-setting.entity';
|
||||
import { CustomDomainStatus } from '../common/enums';
|
||||
|
||||
@Injectable()
|
||||
export class DomainService {
|
||||
private readonly logger = new Logger(DomainService.name);
|
||||
|
||||
constructor(
|
||||
@InjectRepository(Application)
|
||||
private appRepo: Repository<Application>,
|
||||
@InjectRepository(PlatformSetting)
|
||||
private settingsRepo: Repository<PlatformSetting>,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async getCustomDomainPrice(): Promise<number> {
|
||||
const setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'custom_domain_monthly_price_toman' },
|
||||
});
|
||||
return setting ? Number(setting.value) : 0;
|
||||
}
|
||||
|
||||
async getPlatformCnameTarget(): Promise<string> {
|
||||
const setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'platform_cname_target' },
|
||||
});
|
||||
return setting?.value || this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||
}
|
||||
|
||||
async setCustomDomain(appId: string, userId: string, domain: string): Promise<Application> {
|
||||
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||
if (!app) throw new NotFoundException('Application not found');
|
||||
|
||||
const normalizedDomain = domain.toLowerCase().trim();
|
||||
|
||||
const existing = await this.appRepo.findOne({
|
||||
where: {
|
||||
customDomain: normalizedDomain,
|
||||
customDomainStatus: Not(CustomDomainStatus.NONE),
|
||||
id: Not(appId),
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException('This domain is already in use by another application');
|
||||
}
|
||||
|
||||
app.customDomain = normalizedDomain;
|
||||
app.customDomainStatus = CustomDomainStatus.PENDING_DNS;
|
||||
app.customDomainVerifiedAt = undefined;
|
||||
|
||||
const saved = await this.appRepo.save(app);
|
||||
this.logger.log(`Custom domain set for ${app.name}: ${normalizedDomain} (pending DNS)`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async verifyDns(appId: string, userId: string): Promise<{
|
||||
verified: boolean;
|
||||
message: string;
|
||||
application?: Application;
|
||||
}> {
|
||||
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||
if (!app) throw new NotFoundException('Application not found');
|
||||
|
||||
if (!app.customDomain || app.customDomainStatus === CustomDomainStatus.NONE) {
|
||||
throw new BadRequestException('No custom domain is set for this application');
|
||||
}
|
||||
|
||||
const cnameTarget = await this.getPlatformCnameTarget();
|
||||
const expectedSubdomain = `${app.subdomain}.${cnameTarget}`;
|
||||
|
||||
try {
|
||||
const resolved = await this.resolveDomain(app.customDomain);
|
||||
|
||||
const isValid = resolved.some(
|
||||
(r) =>
|
||||
r === cnameTarget ||
|
||||
r === expectedSubdomain ||
|
||||
r.endsWith(`.${cnameTarget}`),
|
||||
);
|
||||
|
||||
if (isValid) {
|
||||
app.customDomainStatus = CustomDomainStatus.VERIFIED;
|
||||
app.customDomainVerifiedAt = new Date();
|
||||
const saved = await this.appRepo.save(app);
|
||||
this.logger.log(`DNS verified for ${app.name}: ${app.customDomain}`);
|
||||
return {
|
||||
verified: true,
|
||||
message: 'DNS verification successful. Your custom domain is now active.',
|
||||
application: saved,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
verified: false,
|
||||
message: `DNS records do not point to ${expectedSubdomain}. Please check your CNAME record and try again.`,
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`DNS verification failed for ${app.customDomain}: ${err.message}`);
|
||||
return {
|
||||
verified: false,
|
||||
message: `Could not resolve DNS for ${app.customDomain}. Make sure the CNAME record is set and DNS has propagated (may take up to 48 hours).`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async removeCustomDomain(appId: string, userId: string): Promise<Application> {
|
||||
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||
if (!app) throw new NotFoundException('Application not found');
|
||||
|
||||
app.customDomain = undefined;
|
||||
app.customDomainStatus = CustomDomainStatus.NONE;
|
||||
app.customDomainVerifiedAt = undefined;
|
||||
|
||||
const saved = await this.appRepo.save(app);
|
||||
this.logger.log(`Custom domain removed for ${app.name}`);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async getDomainInfo(appId: string, userId: string): Promise<{
|
||||
customDomain?: string;
|
||||
customDomainStatus: CustomDomainStatus;
|
||||
customDomainVerifiedAt?: Date;
|
||||
platformDomain: string;
|
||||
fullPlatformUrl: string;
|
||||
cnameTarget: string;
|
||||
instructions: string[];
|
||||
}> {
|
||||
const app = await this.appRepo.findOne({ where: { id: appId, userId } });
|
||||
if (!app) throw new NotFoundException('Application not found');
|
||||
|
||||
const platformDomain = this.configService.get('platform.domain') || 'apps.cloudhost.ir';
|
||||
const cnameTarget = await this.getPlatformCnameTarget();
|
||||
const fullPlatformUrl = `${app.subdomain}.${platformDomain}`;
|
||||
|
||||
const instructions = [
|
||||
`1. وارد پنل مدیریت دامنه خود شوید (مانند Cloudflare، Namecheap، GoDaddy و غیره)`,
|
||||
`2. به بخش مدیریت DNS بروید`,
|
||||
`3. یک رکورد CNAME اضافه کنید:`,
|
||||
` - Name/Host: @ یا www (بسته به دامنهتان)`,
|
||||
` - Type: CNAME`,
|
||||
` - Value/Target: ${fullPlatformUrl}`,
|
||||
`4. اگر از دامنه اصلی (root domain) بدون www استفاده میکنید، برخی ثبتکنندگان از CNAME flattening پشتیبانی میکنند (مانند Cloudflare). در غیر این صورت از www استفاده کنید.`,
|
||||
`5. بین ۵ تا ۳۰ دقیقه صبر کنید تا DNS منتشر شود (تا ۴۸ ساعت ممکن است طول بکشد)`,
|
||||
`6. دکمه "تأیید DNS" را بزنید`,
|
||||
];
|
||||
|
||||
return {
|
||||
customDomain: app.customDomain,
|
||||
customDomainStatus: app.customDomainStatus,
|
||||
customDomainVerifiedAt: app.customDomainVerifiedAt,
|
||||
platformDomain,
|
||||
fullPlatformUrl,
|
||||
cnameTarget,
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveDomain(domain: string): Promise<string[]> {
|
||||
const resolver = new dns.promises.Resolver();
|
||||
resolver.setServers(['8.8.8.8', '1.1.1.1']);
|
||||
const results: string[] = [];
|
||||
|
||||
try {
|
||||
const cnames = await resolver.resolveCname(domain);
|
||||
results.push(...cnames);
|
||||
} catch {
|
||||
// No CNAME record — try A record
|
||||
}
|
||||
|
||||
try {
|
||||
const aRecords = await resolver.resolve4(domain);
|
||||
results.push(...aRecords);
|
||||
} catch {
|
||||
// No A record
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -75,16 +75,31 @@ export class CreateApplicationDto {
|
||||
@IsBoolean()
|
||||
enableRedis?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: '7.2', description: 'Redis version (7.2, 7.0, 6.2, 6.0)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
redisVersion?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable RabbitMQ for message queue' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableRabbitmq?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: '3.13', description: 'RabbitMQ version (3.13, 3.12, 3.11, 3.10)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rabbitmqVersion?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable Elasticsearch for application logging' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableElasticsearch?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: '8.12', description: 'Elasticsearch version (8.12, 8.11, 7.17, 7.10)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
elasticsearchVersion?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: ['/app/logs/*.log'], description: 'Custom log paths to collect' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@@ -152,6 +167,11 @@ export class CreateApplicationDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
poolId?: string;
|
||||
|
||||
@ApiPropertyOptional({ example: 'www.example.com', description: 'Custom domain for the application (requires additional fee)' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
customDomain?: string;
|
||||
}
|
||||
|
||||
export class UpdateApplicationDto {
|
||||
@@ -240,6 +260,15 @@ export class UpdateApplicationDto {
|
||||
logPaths?: string[];
|
||||
}
|
||||
|
||||
export class SetCustomDomainDto {
|
||||
@ApiProperty({ example: 'www.example.com', description: 'The custom domain to assign' })
|
||||
@IsString()
|
||||
@Matches(/^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$/, {
|
||||
message: 'Invalid domain format (e.g. example.com or www.example.com)',
|
||||
})
|
||||
domain: string;
|
||||
}
|
||||
|
||||
export class ScaleResourcesDto {
|
||||
@ApiPropertyOptional({ example: '100m' })
|
||||
@IsOptional()
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
OneToMany,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus } from '../../common/enums';
|
||||
import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus, CustomDomainStatus } from '../../common/enums';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { Deployment } from '../../deployments/entities/deployment.entity';
|
||||
|
||||
@@ -54,12 +54,21 @@ export class Application {
|
||||
@Column({ default: false })
|
||||
enableRedis: boolean;
|
||||
|
||||
@Column({ nullable: true, default: '7.2' })
|
||||
redisVersion: string;
|
||||
|
||||
@Column({ default: false })
|
||||
enableRabbitmq: boolean;
|
||||
|
||||
@Column({ nullable: true, default: '3.13' })
|
||||
rabbitmqVersion: string;
|
||||
|
||||
@Column({ default: false })
|
||||
enableElasticsearch: boolean; // For application logging
|
||||
|
||||
@Column({ nullable: true, default: '8.12' })
|
||||
elasticsearchVersion: string;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
|
||||
|
||||
@@ -124,6 +133,16 @@ export class Application {
|
||||
@Column({ nullable: true })
|
||||
subdomain: string; // <subdomain>.apps.cloudhost.local
|
||||
|
||||
// ── Custom Domain ─────────────────────────────
|
||||
@Column({ nullable: true })
|
||||
customDomain?: string; // e.g. "www.example.com"
|
||||
|
||||
@Column({ type: 'enum', enum: CustomDomainStatus, default: CustomDomainStatus.NONE })
|
||||
customDomainStatus: CustomDomainStatus;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
customDomainVerifiedAt?: Date;
|
||||
|
||||
// ── Billing & Lifecycle ─────────────────────────────
|
||||
@Column({ nullable: true })
|
||||
planId: string; // FK to ServicePlan
|
||||
|
||||
@@ -95,6 +95,24 @@ export class BillingController {
|
||||
return this.billingService.calculateCost(dto);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing ─────────────────────────────────────
|
||||
|
||||
@Get('settings/custom-domain-price')
|
||||
@ApiOperation({ summary: 'Get custom domain monthly price' })
|
||||
async getCustomDomainPrice() {
|
||||
return this.billingService.getCustomDomainPrice();
|
||||
}
|
||||
|
||||
@Patch('settings/custom-domain-price')
|
||||
@Roles(UserRole.ADMIN)
|
||||
@ApiOperation({ summary: 'Set custom domain monthly price (Admin)' })
|
||||
async setCustomDomainPrice(@Body() body: { monthlyPrice: number }) {
|
||||
if (body.monthlyPrice === undefined || body.monthlyPrice < 0) {
|
||||
throw new BadRequestException('monthlyPrice must be a non-negative number');
|
||||
}
|
||||
return this.billingService.setCustomDomainPrice(body.monthlyPrice);
|
||||
}
|
||||
|
||||
// ─── Wallet (User) ───────────────────────────────────────────────
|
||||
|
||||
@Get('wallet')
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ServicePlan } from './entities/service-plan.entity';
|
||||
import { PricingRule } from './entities/pricing-rule.entity';
|
||||
import { Wallet } from './entities/wallet.entity';
|
||||
import { WalletTransaction } from './entities/wallet-transaction.entity';
|
||||
import { PlatformSetting } from './entities/platform-setting.entity';
|
||||
import { TransactionType, BillingCycle, PricingResourceType } from '../common/enums';
|
||||
import {
|
||||
CreateServicePlanDto,
|
||||
@@ -23,6 +24,7 @@ export class BillingService {
|
||||
@InjectRepository(PricingRule) private ruleRepo: Repository<PricingRule>,
|
||||
@InjectRepository(Wallet) private walletRepo: Repository<Wallet>,
|
||||
@InjectRepository(WalletTransaction) private txRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(PlatformSetting) private settingsRepo: Repository<PlatformSetting>,
|
||||
) {}
|
||||
|
||||
// ─── Service Plans ────────────────────────────────────────────────
|
||||
@@ -143,6 +145,7 @@ export class BillingService {
|
||||
const hasRedis = dto.enableRedis || false;
|
||||
const hasRabbitmq = dto.enableRabbitmq || false;
|
||||
const hasElasticsearch = dto.enableElasticsearch || false;
|
||||
const hasCustomDomain = dto.enableCustomDomain || false;
|
||||
|
||||
const breakdown: { label: string; hourly: number; monthly: number; yearly: number }[] = [];
|
||||
let totalBase = 0;
|
||||
@@ -184,6 +187,10 @@ export class BillingService {
|
||||
cost = hasElasticsearch ? Number(rule.unitPrice) : 0;
|
||||
label = 'Elasticsearch addon';
|
||||
break;
|
||||
case PricingResourceType.CUSTOM_DOMAIN_ADDON:
|
||||
cost = hasCustomDomain ? Number(rule.unitPrice) : 0;
|
||||
label = 'Custom domain + SSL';
|
||||
break;
|
||||
}
|
||||
|
||||
if (cost > 0) {
|
||||
@@ -222,6 +229,33 @@ export class BillingService {
|
||||
return parseFloat(memory) / (1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
// ─── Custom Domain Pricing (PlatformSetting) ────────────────────
|
||||
|
||||
async getCustomDomainPrice(): Promise<{ monthlyPrice: number }> {
|
||||
const setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'custom_domain_monthly_price_toman' },
|
||||
});
|
||||
return { monthlyPrice: setting ? Number(setting.value) : 0 };
|
||||
}
|
||||
|
||||
async setCustomDomainPrice(monthlyPrice: number): Promise<{ monthlyPrice: number }> {
|
||||
let setting = await this.settingsRepo.findOne({
|
||||
where: { key: 'custom_domain_monthly_price_toman' },
|
||||
});
|
||||
if (setting) {
|
||||
setting.value = String(monthlyPrice);
|
||||
} else {
|
||||
setting = this.settingsRepo.create({
|
||||
key: 'custom_domain_monthly_price_toman',
|
||||
value: String(monthlyPrice),
|
||||
description: 'Monthly price for custom domain addon (Toman)',
|
||||
});
|
||||
}
|
||||
await this.settingsRepo.save(setting);
|
||||
this.logger.log(`Custom domain monthly price updated: ${monthlyPrice} Toman`);
|
||||
return { monthlyPrice };
|
||||
}
|
||||
|
||||
// ─── Wallet ───────────────────────────────────────────────────────
|
||||
|
||||
async getOrCreateWallet(userId: string): Promise<Wallet> {
|
||||
|
||||
@@ -136,6 +136,11 @@ export class CalculateCostDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableElasticsearch?: boolean;
|
||||
|
||||
@ApiPropertyOptional({ example: false, description: 'Enable custom domain with SSL' })
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enableCustomDomain?: boolean;
|
||||
}
|
||||
|
||||
// ─── Renewal & Upgrade DTOs ─────────────────────────────────────────
|
||||
|
||||
@@ -90,6 +90,7 @@ export enum PricingResourceType {
|
||||
REDIS_ADDON = 'redis_addon', // Price for Redis addon
|
||||
RABBITMQ_ADDON = 'rabbitmq_addon', // Price for RabbitMQ addon
|
||||
ELASTICSEARCH_ADDON = 'elasticsearch_addon', // Price for Elasticsearch addon
|
||||
CUSTOM_DOMAIN_ADDON = 'custom_domain_addon', // Price for custom domain with SSL
|
||||
}
|
||||
|
||||
export enum TransactionType {
|
||||
@@ -98,6 +99,14 @@ export enum TransactionType {
|
||||
REFUND = 'refund', // Refund
|
||||
}
|
||||
|
||||
// ── Custom Domain ────────────────────────────────────
|
||||
|
||||
export enum CustomDomainStatus {
|
||||
NONE = 'none',
|
||||
PENDING_DNS = 'pending_dns',
|
||||
VERIFIED = 'verified',
|
||||
}
|
||||
|
||||
// ── Application Lifecycle ─────────────────────────────
|
||||
|
||||
export enum AppLifecycleStatus {
|
||||
|
||||
@@ -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` }],
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { AppModule } from './app.module';
|
||||
import { UsersService } from './users/users.service';
|
||||
import { PlatformSetting } from './billing/entities/platform-setting.entity';
|
||||
import { UserRole } from './common/enums';
|
||||
|
||||
/**
|
||||
@@ -47,6 +50,24 @@ async function bootstrap() {
|
||||
console.log(` Email: ${email}`);
|
||||
console.log(` Password: ${password}`);
|
||||
|
||||
// Seed default platform settings
|
||||
const settingsRepo = app.get<Repository<PlatformSetting>>(getRepositoryToken(PlatformSetting));
|
||||
|
||||
const defaults = [
|
||||
{ key: 'custom_domain_monthly_price_toman', value: '50000', description: 'Monthly price for custom domain addon (Toman)' },
|
||||
{ key: 'platform_cname_target', value: 'apps.cloudhost.ir', description: 'CNAME target shown to users for custom domain setup' },
|
||||
];
|
||||
|
||||
for (const d of defaults) {
|
||||
const existing = await settingsRepo.findOne({ where: { key: d.key } });
|
||||
if (!existing) {
|
||||
await settingsRepo.save(settingsRepo.create(d));
|
||||
console.log(`✅ Platform setting seeded: ${d.key} = ${d.value}`);
|
||||
} else {
|
||||
console.log(`⚠️ Platform setting already exists: ${d.key} = ${existing.value}`);
|
||||
}
|
||||
}
|
||||
|
||||
await app.close();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user