Add managed databases and services with billing-aligned upgrades.

Introduce product types for managed PostgreSQL, Redis, and RabbitMQ with a dedicated dashboard, Helm-only deploy pipeline, external access, snapshots with progress, and prorated resource or storage upgrades matching application billing rules. PVCs use an expandable StorageClass with automatic migration when legacy disks cannot resize in place.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-05-23 19:00:09 +03:30
parent 736509708b
commit 695e05f948
55 changed files with 5575 additions and 600 deletions
+32 -9
View File
@@ -20,6 +20,7 @@ import {
ServiceAccessGrantStatus,
DatabaseType,
AppLifecycleStatus,
ProductType,
} from '../common/enums';
import { CreateServiceAccessDto } from './dto/service-access.dto';
@@ -29,6 +30,7 @@ export interface RevokeAccessJobData {
const MIN_DURATION_MINUTES = 15;
const DEFAULT_MAX_DURATION_MINUTES = 240;
const PERSISTENT_ACCESS_EXPIRES_AT = new Date('2099-12-31T23:59:59.000Z');
@Injectable()
export class AccessService implements OnModuleInit {
@@ -54,6 +56,7 @@ export class AccessService implements OnModuleInit {
},
});
for (const grant of expired) {
if (grant.persistent) continue;
try {
await this.revokeGrant(grant.id, grant.userId, true);
} catch (e: any) {
@@ -96,16 +99,23 @@ export class AccessService implements OnModuleInit {
private validateTargetEnabled(app: Application, target: ServiceAccessTarget): void {
switch (target) {
case ServiceAccessTarget.DATABASE:
if (!app.databaseType || app.databaseType === DatabaseType.NONE) {
if (
(!app.databaseType || app.databaseType === DatabaseType.NONE) &&
app.productType !== ProductType.MANAGED_DATABASE
) {
throw new BadRequestException('Application has no database');
}
break;
case ServiceAccessTarget.REDIS:
if (!app.enableRedis) throw new BadRequestException('Redis is not enabled');
if (!app.enableRedis && app.productType !== ProductType.MANAGED_REDIS) {
throw new BadRequestException('Redis is not enabled');
}
break;
case ServiceAccessTarget.RABBITMQ_AMQP:
case ServiceAccessTarget.RABBITMQ_MANAGEMENT:
if (!app.enableRabbitmq) throw new BadRequestException('RabbitMQ is not enabled');
if (!app.enableRabbitmq && app.productType !== ProductType.MANAGED_RABBITMQ) {
throw new BadRequestException('RabbitMQ is not enabled');
}
break;
}
}
@@ -175,9 +185,15 @@ export class AccessService implements OnModuleInit {
await this.validateApplicationForAccess(app);
this.validateTargetEnabled(app, dto.target);
const maxDuration = await this.getMaxDurationMinutes();
if (dto.durationMinutes > maxDuration) {
throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`);
const persistent = !!dto.persistent;
if (!persistent) {
if (!dto.durationMinutes) {
throw new BadRequestException('durationMinutes is required for temporary access');
}
const maxDuration = await this.getMaxDurationMinutes();
if (dto.durationMinutes > maxDuration) {
throw new BadRequestException(`Duration cannot exceed ${maxDuration} minutes`);
}
}
const existing = await this.grantsRepo.findOne({
@@ -195,7 +211,9 @@ export class AccessService implements OnModuleInit {
const grantId = uuidv4();
const namespace = this.kubernetesService.getUserNamespace(app.userId);
const expiresAt = new Date(Date.now() + dto.durationMinutes * 60 * 1000);
const expiresAt = persistent
? PERSISTENT_ACCESS_EXPIRES_AT
: new Date(Date.now() + (dto.durationMinutes as number) * 60 * 1000);
let k8sResult: { host: string; nodePort: number; k8sServiceName: string; targetPort: number };
try {
@@ -216,13 +234,16 @@ export class AccessService implements OnModuleInit {
host: k8sResult.host,
k8sServiceName: k8sResult.k8sServiceName,
status: ServiceAccessGrantStatus.ACTIVE,
persistent,
expiresAt,
});
await this.grantsRepo.save(grant);
const delayMs = Math.max(0, expiresAt.getTime() - Date.now());
await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId });
if (!persistent) {
const delayMs = Math.max(0, expiresAt.getTime() - Date.now());
await this.revokeQueue.add('revoke', { grantId }, { delay: delayMs, jobId: grantId });
}
const creds = await this.kubernetesService.readAccessCredentials(app, dto.target);
const connection = this.buildConnection(
@@ -240,6 +261,7 @@ export class AccessService implements OnModuleInit {
port: grant.nodePort,
targetPort: grant.targetPort,
expiresAt: grant.expiresAt,
persistent: grant.persistent,
status: grant.status,
connection,
};
@@ -279,6 +301,7 @@ export class AccessService implements OnModuleInit {
port: grant.nodePort,
targetPort: grant.targetPort,
expiresAt: grant.expiresAt,
persistent: grant.persistent,
status: grant.status,
connection,
};
+15 -3
View File
@@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsInt, Min, Max } from 'class-validator';
import { IsEnum, IsInt, Min, Max, IsBoolean, IsOptional, ValidateIf } from 'class-validator';
import { ServiceAccessTarget } from '../../common/enums';
export class CreateServiceAccessDto {
@@ -7,11 +7,20 @@ export class CreateServiceAccessDto {
@IsEnum(ServiceAccessTarget)
target: ServiceAccessTarget;
@ApiProperty({ example: 60, description: 'Access duration in minutes (min 15)' })
@ApiPropertyOptional({
example: false,
description: 'Keep NodePort open until manually revoked (ignores durationMinutes)',
})
@IsOptional()
@IsBoolean()
persistent?: boolean;
@ApiPropertyOptional({ example: 60, description: 'Access duration in minutes (min 15), required when not persistent' })
@ValidateIf((o) => !o.persistent)
@IsInt()
@Min(15)
@Max(1440)
durationMinutes: number;
durationMinutes?: number;
}
export class ServiceAccessConnectionDto {
@@ -53,6 +62,9 @@ export class ServiceAccessGrantResponseDto {
@ApiProperty()
expiresAt: Date;
@ApiPropertyOptional()
persistent?: boolean;
@ApiProperty()
status: string;
@@ -50,6 +50,10 @@ export class ServiceAccessGrant {
@Column({ type: 'enum', enum: ServiceAccessGrantStatus, default: ServiceAccessGrantStatus.ACTIVE })
status: ServiceAccessGrantStatus;
/** When true, access stays open until manually revoked (no auto-expiry job). */
@Column({ default: false })
persistent: boolean;
@Column({ type: 'timestamptz' })
expiresAt: Date;
@@ -200,9 +200,12 @@ export class ApplicationsController {
}
@Get()
@ApiOperation({ summary: 'List my applications' })
async findAll(@Request() req: any) {
return this.applicationsService.findAllByUser(req.user.id);
@ApiOperation({ summary: 'List my applications or managed services' })
async findAll(
@Request() req: any,
@Query('productType') productType?: 'application' | 'managed',
) {
return this.applicationsService.findAllByUser(req.user.id, { productType });
}
@Get('all')
@@ -299,19 +302,30 @@ export class ApplicationsController {
// Update in K8s (live)
await this.kubernetesService.updateResources(app, dto, workload);
// Update in DB — only main app resources are stored on the Application entity
const updateFields: any = {};
const updateFields: Record<string, unknown> = {};
if (workload === 'app') {
if (dto.cpuRequest) updateFields.cpuRequest = dto.cpuRequest;
if (dto.cpuLimit) updateFields.cpuLimit = dto.cpuLimit;
if (dto.memoryRequest) updateFields.memoryRequest = dto.memoryRequest;
if (dto.memoryLimit) updateFields.memoryLimit = dto.memoryLimit;
if (dto.replicas !== undefined) updateFields.replicas = dto.replicas;
} else if (workload === 'redis' || workload === 'rabbitmq') {
const prev = app.optionalServiceResources?.[workload];
updateFields.optionalServiceResources = {
...app.optionalServiceResources,
[workload]: {
cpuRequest: dto.cpuRequest ?? prev?.cpuRequest,
cpuLimit: dto.cpuLimit ?? prev?.cpuLimit ?? '200m',
memoryRequest: dto.memoryRequest ?? prev?.memoryRequest,
memoryLimit: dto.memoryLimit ?? prev?.memoryLimit ?? '256Mi',
storageGi: prev?.storageGi ?? (workload === 'redis' ? 1 : 2),
},
};
}
const updated =
Object.keys(updateFields).length > 0
? await this.applicationsService.update(id, app.userId, updateFields)
? await this.applicationsService.update(id, app.userId, updateFields as any)
: app;
this.logger.log(`Updated resources for ${app.name}: ${JSON.stringify(dto)}`);
return updated;
@@ -8,8 +8,16 @@ 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, CustomDomainStatus, AppRuntime } from '../common/enums';
import {
UserRole,
DatabaseType,
CustomDomainStatus,
AppRuntime,
ProductType,
isManagedProductType,
} from '../common/enums';
import { ensureAppUrlEnv } from './app-url.util';
import { normalizeCreateApplicationDto } from './managed-service.util';
@Injectable()
export class ApplicationsService {
@@ -23,6 +31,9 @@ export class ApplicationsService {
) {}
async create(userId: string, dto: CreateApplicationDto, userRole?: string): Promise<Application> {
dto = normalizeCreateApplicationDto(dto);
const productType = dto.productType ?? ProductType.APPLICATION;
// End users and technical staff cannot influence placement; only admins may manually assign.
const isAdmin = userRole === UserRole.ADMIN;
if (!isAdmin) {
@@ -63,9 +74,12 @@ export class ApplicationsService {
this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`);
}
const customDomain = dto.customDomain?.toLowerCase().trim() || undefined;
const customDomain = isManagedProductType(productType)
? undefined
: dto.customDomain?.toLowerCase().trim() || undefined;
const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(dto.runtime)
const runtime = dto.runtime ?? AppRuntime.NODEJS;
const defaultPort = [AppRuntime.WORDPRESS, AppRuntime.PHP, AppRuntime.LARAVEL].includes(runtime)
? 80
: 3000;
@@ -74,26 +88,31 @@ export class ApplicationsService {
const app = this.appsRepository.create({
...dto,
productType,
runtime,
userId,
clusterId,
poolId,
dbUsername,
dbPassword,
replicas: isManagedProductType(productType) ? 0 : (dto.replicas ?? 1),
port: dto.port ?? defaultPort,
subdomain,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: ensureAppUrlEnv(
{
name: dto.name,
runtime: dto.runtime,
subdomain,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: dto.envVars ?? {},
},
platformDomain,
),
envVars: isManagedProductType(productType)
? (dto.envVars ?? {})
: ensureAppUrlEnv(
{
name: dto.name,
runtime,
subdomain,
customDomain: customDomain || undefined,
customDomainStatus: customDomain ? CustomDomainStatus.PENDING_DNS : CustomDomainStatus.NONE,
envVars: dto.envVars ?? {},
},
platformDomain,
),
});
const saved = await this.appsRepository.save(app);
if (allocationLogId) {
@@ -102,12 +121,32 @@ export class ApplicationsService {
return saved;
}
async findAllByUser(userId: string): Promise<Application[]> {
return this.appsRepository.find({
where: { userId },
relations: ['deployments'],
order: { createdAt: 'DESC' },
});
async findAllByUser(
userId: string,
options?: { productType?: 'application' | 'managed' },
): Promise<Application[]> {
const qb = this.appsRepository
.createQueryBuilder('app')
.leftJoinAndSelect('app.deployments', 'deployments')
.where('app.userId = :userId', { userId })
.orderBy('app.createdAt', 'DESC');
if (options?.productType === 'application') {
qb.andWhere(
'(app.productType = :applicationType OR app.productType IS NULL)',
{ applicationType: ProductType.APPLICATION },
);
} else if (options?.productType === 'managed') {
qb.andWhere('app.productType IN (:...managedTypes)', {
managedTypes: [
ProductType.MANAGED_DATABASE,
ProductType.MANAGED_REDIS,
ProductType.MANAGED_RABBITMQ,
],
});
}
return qb.getMany();
}
async findAll(search?: string): Promise<Application[]> {
@@ -14,7 +14,7 @@ import {
} from 'class-validator';
import { Type } from 'class-transformer';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { AppRuntime, DatabaseType } from '../../common/enums';
import { AppRuntime, DatabaseType, ProductType } from '../../common/enums';
import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto';
export class OptionalServiceResourcesMapDto {
@@ -44,9 +44,19 @@ export class CreateApplicationDto {
@IsString()
description?: string;
@ApiProperty({ enum: AppRuntime, example: AppRuntime.NODEJS })
@ApiPropertyOptional({
enum: ProductType,
default: ProductType.APPLICATION,
description: 'application (default) | managed_database | managed_redis | managed_rabbitmq',
})
@IsOptional()
@IsEnum(ProductType)
productType?: ProductType;
@ApiPropertyOptional({ enum: AppRuntime, example: AppRuntime.NODEJS })
@IsOptional()
@IsEnum(AppRuntime)
runtime: AppRuntime;
runtime?: AppRuntime;
@ApiProperty({ enum: DatabaseType, example: DatabaseType.POSTGRESQL })
@IsEnum(DatabaseType)
@@ -8,7 +8,14 @@ import {
OneToMany,
JoinColumn,
} from 'typeorm';
import { AppRuntime, DatabaseType, BillingCycle, AppLifecycleStatus, CustomDomainStatus } from '../../common/enums';
import {
AppRuntime,
DatabaseType,
BillingCycle,
AppLifecycleStatus,
CustomDomainStatus,
ProductType,
} from '../../common/enums';
import { User } from '../../users/entities/user.entity';
import { Deployment } from '../../deployments/entities/deployment.entity';
@@ -20,6 +27,9 @@ export class Application {
@Column()
name: string;
@Column({ type: 'varchar', default: ProductType.APPLICATION })
productType: ProductType;
@Column({ nullable: true })
description: string;
@@ -0,0 +1,72 @@
import { BadRequestException } from '@nestjs/common';
import { CreateApplicationDto } from './dto/application.dto';
import {
AppRuntime,
DatabaseType,
ProductType,
isManagedProductType,
} from '../common/enums';
const DB_CPU_LIMIT = '500m';
const DB_MEMORY_LIMIT = '512Mi';
/** Normalize and validate create payload for managed vs full applications. */
export function normalizeCreateApplicationDto(dto: CreateApplicationDto): CreateApplicationDto {
const productType = dto.productType ?? ProductType.APPLICATION;
if (!isManagedProductType(productType)) {
if (!dto.runtime) {
throw new BadRequestException('runtime is required for applications');
}
return { ...dto, productType: ProductType.APPLICATION };
}
if (dto.enableElasticsearch) {
throw new BadRequestException('Elasticsearch is not available for managed services');
}
if (dto.customDomain) {
throw new BadRequestException('Custom domains are not available for managed services');
}
const normalized: CreateApplicationDto = {
...dto,
productType,
runtime: dto.runtime ?? AppRuntime.NODEJS,
replicas: 0,
cpuLimit: dto.cpuLimit ?? '100m',
memoryLimit: dto.memoryLimit ?? '128Mi',
enableElasticsearch: false,
customDomain: undefined,
};
switch (productType) {
case ProductType.MANAGED_DATABASE:
if (!dto.databaseType || dto.databaseType === DatabaseType.NONE) {
throw new BadRequestException('databaseType is required for managed database services');
}
normalized.databaseType = dto.databaseType;
normalized.enableRedis = false;
normalized.enableRabbitmq = false;
normalized.cpuRequest = dto.cpuRequest ?? '100m';
normalized.cpuLimit = dto.cpuLimit ?? DB_CPU_LIMIT;
normalized.memoryRequest = dto.memoryRequest ?? '256Mi';
normalized.memoryLimit = dto.memoryLimit ?? DB_MEMORY_LIMIT;
break;
case ProductType.MANAGED_REDIS:
normalized.databaseType = DatabaseType.NONE;
normalized.enableRedis = true;
normalized.enableRabbitmq = false;
if (!dto.redisVersion) normalized.redisVersion = '7.2';
break;
case ProductType.MANAGED_RABBITMQ:
normalized.databaseType = DatabaseType.NONE;
normalized.enableRedis = false;
normalized.enableRabbitmq = true;
if (!dto.rabbitmqVersion) normalized.rabbitmqVersion = '3.13';
break;
default:
break;
}
return normalized;
}
+154 -46
View File
@@ -37,7 +37,17 @@ import {
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole, BillingCycle, AppLifecycleStatus, InvoiceReason, InvoiceStatus, PaymentMethod } from '../common/enums';
import {
UserRole,
BillingCycle,
AppLifecycleStatus,
InvoiceReason,
InvoiceStatus,
PaymentMethod,
ProductType,
DatabaseType,
} from '../common/enums';
import { Application } from '../applications/entities/application.entity';
@ApiTags('Billing')
@ApiBearerAuth()
@@ -722,33 +732,15 @@ export class BillingController {
paidInvoice = paid.invoice;
}
// Apply the resource changes
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
cpuRequest: dto.cpuRequest || app.cpuRequest,
cpuLimit: dto.cpuLimit || app.cpuLimit,
memoryRequest: dto.memoryRequest || app.memoryRequest,
memoryLimit: dto.memoryLimit || app.memoryLimit,
replicas: dto.replicas ?? app.replicas,
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
appStorageSize: dto.appStorageSize || app.appStorageSize,
});
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, dto),
);
// Update Kubernetes resources
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
// Resize app storage PVC if changed
if (dto.appStorageSize && dto.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, dto.appStorageSize);
}
await this.applyUpgradeToKubernetes(updatedApp, dto, app);
} catch (e: any) {
// Log error but don't fail - DB is updated, K8s will sync on next deploy
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
@@ -803,29 +795,15 @@ export class BillingController {
if (action === 'upgrade') {
const app = await this.applicationsService.findOne(invoice.applicationId);
const resources = invoice.metadata?.resources || {};
const updatedApp = await this.applicationsService.update(app.id, app.userId, {
cpuRequest: resources.cpuRequest || app.cpuRequest,
cpuLimit: resources.cpuLimit || app.cpuLimit,
memoryRequest: resources.memoryRequest || app.memoryRequest,
memoryLimit: resources.memoryLimit || app.memoryLimit,
replicas: resources.replicas ?? app.replicas,
dbStorageSize: resources.dbStorageSize || app.dbStorageSize,
appStorageSize: resources.appStorageSize || app.appStorageSize,
});
const resources = (invoice.metadata?.resources || {}) as UpgradeResourcesDto;
const updatedApp = await this.applicationsService.update(
app.id,
app.userId,
this.buildUpgradeEntityPatch(app, resources),
);
try {
await this.kubernetesService.updateResources(updatedApp, {
cpuRequest: resources.cpuRequest,
cpuLimit: resources.cpuLimit,
memoryRequest: resources.memoryRequest,
memoryLimit: resources.memoryLimit,
replicas: resources.replicas,
});
if (resources.appStorageSize && resources.appStorageSize !== app.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(updatedApp, resources.appStorageSize);
}
await this.applyUpgradeToKubernetes(updatedApp, resources, app);
} catch (e: any) {
console.warn(`K8s resource update failed for ${app.name}: ${e.message}`);
}
@@ -849,6 +827,136 @@ export class BillingController {
return null;
}
private buildUpgradeEntityPatch(app: Application, dto: UpgradeResourcesDto): Partial<Application> {
const pt = app.productType ?? ProductType.APPLICATION;
if (pt === ProductType.MANAGED_REDIS && dto.redisResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
redis: {
...app.optionalServiceResources?.redis,
...dto.redisResources,
storageGi:
dto.redisResources.storageGi ?? app.optionalServiceResources?.redis?.storageGi ?? 1,
},
},
};
}
if (pt === ProductType.MANAGED_RABBITMQ && dto.rabbitmqResources) {
return {
optionalServiceResources: {
...app.optionalServiceResources,
rabbitmq: {
...app.optionalServiceResources?.rabbitmq,
...dto.rabbitmqResources,
storageGi:
dto.rabbitmqResources.storageGi ??
app.optionalServiceResources?.rabbitmq?.storageGi ??
2,
},
},
};
}
return {
cpuRequest: dto.cpuRequest || app.cpuRequest,
cpuLimit: dto.cpuLimit || app.cpuLimit,
memoryRequest: dto.memoryRequest || app.memoryRequest,
memoryLimit: dto.memoryLimit || app.memoryLimit,
replicas: dto.replicas ?? app.replicas,
dbStorageSize: dto.dbStorageSize || app.dbStorageSize,
appStorageSize: dto.appStorageSize || app.appStorageSize,
};
}
private async applyUpgradeToKubernetes(
app: Application,
dto: UpgradeResourcesDto,
previous: Application,
): Promise<void> {
const pt = app.productType ?? ProductType.APPLICATION;
if (pt === ProductType.MANAGED_DATABASE) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
},
'database',
);
if (dto.dbStorageSize && dto.dbStorageSize !== previous.dbStorageSize) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
return;
}
if (pt === ProductType.MANAGED_REDIS) {
const res = app.optionalServiceResources?.redis;
if (res) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: res.cpuRequest,
cpuLimit: res.cpuLimit,
memoryRequest: res.memoryRequest,
memoryLimit: res.memoryLimit,
},
'redis',
);
}
return;
}
if (pt === ProductType.MANAGED_RABBITMQ) {
const res = app.optionalServiceResources?.rabbitmq;
if (res) {
await this.kubernetesService.updateResources(
app,
{
cpuRequest: res.cpuRequest,
cpuLimit: res.cpuLimit,
memoryRequest: res.memoryRequest,
memoryLimit: res.memoryLimit,
},
'rabbitmq',
);
}
return;
}
await this.kubernetesService.updateResources(app, {
cpuRequest: dto.cpuRequest,
cpuLimit: dto.cpuLimit,
memoryRequest: dto.memoryRequest,
memoryLimit: dto.memoryLimit,
replicas: dto.replicas,
});
if (dto.appStorageSize && dto.appStorageSize !== previous.appStorageSize) {
await this.kubernetesService.resizeAppStoragePvc(app, dto.appStorageSize);
}
if (
dto.dbStorageSize &&
dto.dbStorageSize !== previous.dbStorageSize &&
previous.databaseType &&
previous.databaseType !== DatabaseType.NONE
) {
const resize = await this.kubernetesService.resizeDatabasePvc(app, dto.dbStorageSize);
if (!resize.success) {
throw new BadRequestException(resize.message);
}
}
}
private async getAppWithAccess(user: any, applicationId: string) {
const isAdminOrSales = user.role === UserRole.ADMIN || user.role === UserRole.SALES;
+64 -31
View File
@@ -13,6 +13,8 @@ import {
InvoiceStatus,
PaymentMethod,
UserRole,
ProductType,
isManagedProductType,
} from '../common/enums';
import { CalculateCostDto, UpgradeResourcesDto } from './dto/billing.dto';
import { UpdatePricingCatalogDto } from './dto/pricing-catalog.dto';
@@ -560,6 +562,7 @@ export class BillingService {
* Used by lifecycle service for auto-renew.
*/
async calculateCostForApp(app: {
productType?: ProductType;
runtime: string;
databaseType: string;
cpuLimit: string;
@@ -572,20 +575,9 @@ export class BillingService {
enableElasticsearch?: boolean;
customDomain?: string;
customDomainStatus?: string;
optionalServiceResources?: Application['optionalServiceResources'];
}): Promise<{ hourly: number; monthly: number; yearly: number }> {
const result = await this.calculateCost({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas,
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: app.enableRedis,
enableRabbitmq: app.enableRabbitmq,
enableElasticsearch: app.enableElasticsearch,
enableCustomDomain: !!app.customDomain && app.customDomainStatus === 'verified',
});
const result = await this.calculateCost(this.toCalculateDto(this.appToResourceConfig(app as Application)));
return { hourly: result.hourly, monthly: result.monthly, yearly: result.yearly };
}
@@ -635,19 +627,43 @@ export class BillingService {
remainingHours: number;
billingCycle: BillingCycle | null;
}> {
// Current cost
const currentCost = await this.calculateCostForApp(app);
// New cost with upgraded resources
const newCost = await this.calculateCost({
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: newResources.cpuLimit || app.cpuLimit,
memoryLimit: newResources.memoryLimit || app.memoryLimit,
replicas: newResources.replicas ?? app.replicas,
dbStorageSize: newResources.dbStorageSize || app.dbStorageSize,
appStorageSize: newResources.appStorageSize || app.appStorageSize,
});
const base = this.appToResourceConfig(app);
const merged = {
...base,
...(newResources.cpuLimit && { cpuLimit: newResources.cpuLimit }),
...(newResources.memoryLimit && { memoryLimit: newResources.memoryLimit }),
replicas: newResources.replicas ?? base.replicas,
...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }),
...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }),
...(newResources.redisResources && {
redisResources: {
...base.redisResources,
...newResources.redisResources,
storageGi:
newResources.redisResources.storageGi ??
base.redisResources?.storageGi ??
1,
},
}),
...(newResources.rabbitmqResources && {
rabbitmqResources: {
...base.rabbitmqResources,
...newResources.rabbitmqResources,
storageGi:
newResources.rabbitmqResources.storageGi ??
base.rabbitmqResources?.storageGi ??
2,
},
}),
};
const newCostResult = await this.calculateCost(this.toCalculateDto(merged));
const newCost = {
hourly: newCostResult.hourly,
monthly: newCostResult.monthly,
yearly: newCostResult.yearly,
};
// Difference
const difference = {
@@ -719,18 +735,24 @@ export class BillingService {
: undefined;
const dtoExtras =
'redisResources' in app ? (app as CalculateCostDto) : undefined;
const productType =
'productType' in app
? ((app as Application).productType ?? ProductType.APPLICATION)
: ((app as CalculateCostDto).productType ?? ProductType.APPLICATION);
const managed = isManagedProductType(productType);
return {
productType,
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
memoryLimit: app.memoryLimit,
replicas: app.replicas || 1,
replicas: managed ? 0 : (app.replicas ?? 1),
dbStorageSize: app.dbStorageSize,
appStorageSize: app.appStorageSize,
enableRedis: !!app.enableRedis,
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: !!app.enableElasticsearch,
enableCustomDomain,
enableElasticsearch: managed ? false : !!app.enableElasticsearch,
enableCustomDomain: managed ? false : enableCustomDomain,
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
};
@@ -744,6 +766,7 @@ export class BillingService {
const credit = this.creditRepo.create({
userId: app.userId,
sourceAppName: app.name,
productType: app.productType ?? ProductType.APPLICATION,
runtime: app.runtime,
databaseType: app.databaseType,
cpuLimit: app.cpuLimit,
@@ -781,6 +804,7 @@ export class BillingService {
return {
id: credit.id,
sourceAppName: credit.sourceAppName,
productType: credit.productType ?? ProductType.APPLICATION,
runtime: credit.runtime,
databaseType: credit.databaseType,
cpuLimit: credit.cpuLimit,
@@ -803,6 +827,9 @@ export class BillingService {
config: ReturnType<typeof this.appToResourceConfig>,
credit: ResourceCredit,
): boolean {
const configProduct = config.productType ?? ProductType.APPLICATION;
const creditProduct = credit.productType ?? ProductType.APPLICATION;
if (configProduct !== creditProduct) return false;
if (config.runtime !== credit.runtime) return false;
if (
credit.databaseType !== DatabaseType.NONE &&
@@ -842,12 +869,16 @@ export class BillingService {
): Promise<ResourceCredit | null> {
const credits = await this.getActiveCredits(userId);
return (
credits.find(
(c) =>
credits.find((c) => {
const creditProduct = c.productType ?? ProductType.APPLICATION;
const configProduct = config.productType ?? ProductType.APPLICATION;
if (creditProduct !== configProduct) return false;
return (
c.runtime === config.runtime &&
(c.databaseType === DatabaseType.NONE ||
c.databaseType === config.databaseType),
) ?? null
c.databaseType === config.databaseType)
);
}) ?? null
);
}
@@ -865,6 +896,7 @@ export class BillingService {
config: ReturnType<typeof this.appToResourceConfig>,
): CalculateCostDto {
return {
productType: config.productType,
runtime: config.runtime,
databaseType: config.databaseType,
cpuLimit: config.cpuLimit,
@@ -887,6 +919,7 @@ export class BillingService {
patch: Partial<CalculateCostDto> = {},
): CalculateCostDto {
return {
productType: credit.productType ?? ProductType.APPLICATION,
runtime: credit.runtime,
databaseType: credit.databaseType,
cpuLimit: credit.cpuLimit,
+22 -4
View File
@@ -1,7 +1,7 @@
import { IsString, IsEnum, IsOptional, IsBoolean, IsNumber, IsArray, ValidateNested, Min } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus } from '../../common/enums';
import { BillingCycle, PricingResourceType, AppRuntime, InvoiceStatus, ProductType } from '../../common/enums';
import { OptionalServiceResourcesDto } from './optional-service-resources.dto';
export class CreatePricingRuleDto {
@@ -92,6 +92,11 @@ export class ChargeWalletDto {
}
export class CalculateCostDto {
@ApiPropertyOptional({ enum: ProductType, default: ProductType.APPLICATION })
@IsOptional()
@IsEnum(ProductType)
productType?: ProductType;
@ApiProperty({ example: 'nodejs' })
@IsString()
runtime: string;
@@ -108,10 +113,11 @@ export class CalculateCostDto {
@IsString()
memoryLimit: string;
@ApiProperty({ example: 1, description: 'Number of replicas' })
@ApiPropertyOptional({ example: 1, description: 'Number of replicas (0 for managed services)' })
@IsOptional()
@IsNumber()
@Min(1)
replicas: number;
@Min(0)
replicas?: number;
@ApiProperty({ example: '1Gi', description: 'Database storage size' })
@IsOptional()
@@ -206,6 +212,18 @@ export class UpgradeResourcesDto {
@IsOptional()
@IsString()
appStorageSize?: string;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
redisResources?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed RabbitMQ resource limits' })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
rabbitmqResources?: OptionalServiceResourcesDto;
}
export class CalculateUpgradeCostDto extends UpgradeResourcesDto {}
@@ -7,7 +7,7 @@ import {
JoinColumn,
} from 'typeorm';
import { User } from '../../users/entities/user.entity';
import { AppRuntime, DatabaseType, BillingCycle } from '../../common/enums';
import { AppRuntime, DatabaseType, BillingCycle, ProductType } from '../../common/enums';
/** Prepaid resources returned to the user when they delete an app before plan expiry. */
@Entity('resource_credits')
@@ -25,6 +25,9 @@ export class ResourceCredit {
@Column({ nullable: true })
sourceAppName: string;
@Column({ type: 'varchar', default: ProductType.APPLICATION })
productType: ProductType;
@Column({ type: 'enum', enum: AppRuntime })
runtime: AppRuntime;
@@ -11,6 +11,7 @@ import {
DatabaseType,
OptionalService,
PricingResourceType,
ProductType,
} from '../common/enums';
import { OPTIONAL_SERVICE_DEPLOY_SPECS } from './pricing-catalog.constants';
import { CalculateCostDto } from './dto/billing.dto';
@@ -207,4 +208,92 @@ describe('PricingCatalogService', () => {
expect(service.amountForCycleFromLine(line, BillingCycle.MONTHLY)).toBe(2);
expect(service.amountForCycleFromLine(line, BillingCycle.YEARLY)).toBe(3);
});
it('managed_database bills database addon and resources without app base fee', () => {
const rates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 999,
monthlyPrice: 999,
yearlyPrice: 999,
isActive: true,
},
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.DATABASE_ADDON,
hourlyPrice: 0,
monthlyPrice: 500,
yearlyPrice: 0,
isActive: true,
},
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.STORAGE_PER_GB,
hourlyPrice: 0,
monthlyPrice: 100,
yearlyPrice: 0,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(
{
...baseDto(),
productType: ProductType.MANAGED_DATABASE,
databaseType: DatabaseType.POSTGRESQL,
replicas: 0,
dbStorageSize: '2Gi',
cpuLimit: '500m',
memoryLimit: '512Mi',
},
rates,
emptyOptional(),
);
expect(result.monthly).toBe(700);
expect(result.breakdown.some((l) => l.label.includes('Base fee'))).toBe(false);
});
it('managed_redis bills only optional redis lines', () => {
const profile = {
service: OptionalService.REDIS,
cpuLimit: OPTIONAL_SERVICE_DEPLOY_SPECS[OptionalService.REDIS].cpuLimit,
memoryLimit: '256Mi',
storageGi: 0,
} as OptionalServiceProfile;
const rates = [
{
service: OptionalService.REDIS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 0,
monthlyPrice: 250,
yearlyPrice: 0,
isActive: true,
},
] as OptionalServiceRate[];
const appRates = [
{
runtime: AppRuntime.NODEJS,
resourceType: PricingResourceType.BASE_FEE,
hourlyPrice: 999,
monthlyPrice: 999,
yearlyPrice: 999,
isActive: true,
},
] as PricingRate[];
const result = service.computeTotalsWithRates(
{
...baseDto(),
productType: ProductType.MANAGED_REDIS,
replicas: 0,
enableRedis: true,
},
appRates,
{ profiles: [profile], rates, customDomain: null },
);
expect(result.monthly).toBe(250);
});
});
+64 -1
View File
@@ -11,6 +11,7 @@ import {
DatabaseType,
OptionalService,
PricingResourceType,
ProductType,
} from '../common/enums';
import { CalculateCostDto } from './dto/billing.dto';
import {
@@ -329,6 +330,19 @@ export class PricingCatalogService implements OnModuleInit {
rates: PricingRate[],
optional: OptionalBillingContext,
): CostBreakdownLine[] {
const productType = dto.productType ?? ProductType.APPLICATION;
if (
productType === ProductType.MANAGED_REDIS ||
productType === ProductType.MANAGED_RABBITMQ
) {
return this.buildOptionalServiceLines(dto, optional);
}
if (productType === ProductType.MANAGED_DATABASE) {
return this.buildManagedDatabaseLines(dto, rates);
}
const lines: CostBreakdownLine[] = [];
const quantities = this.getQuantities(dto);
@@ -351,6 +365,52 @@ export class PricingCatalogService implements OnModuleInit {
return lines;
}
private buildManagedDatabaseLines(
dto: CalculateCostDto,
rates: PricingRate[],
): CostBreakdownLine[] {
const lines: CostBreakdownLine[] = [];
const quantities = this.getManagedDatabaseQuantities(dto);
const allowed = new Set([
PricingResourceType.DATABASE_ADDON,
PricingResourceType.CPU_PER_CORE,
PricingResourceType.MEMORY_PER_GB,
PricingResourceType.STORAGE_PER_GB,
]);
for (const rate of rates) {
if (!allowed.has(rate.resourceType)) continue;
const qty = quantities.get(rate.resourceType) ?? 0;
if (qty <= 0) continue;
const line = this.lineFromPrices(
RESOURCE_LABELS[rate.resourceType],
qty,
Number(rate.hourlyPrice),
Number(rate.monthlyPrice),
Number(rate.yearlyPrice),
rate.resourceType,
dto,
);
if (line) lines.push(line);
}
return lines;
}
private getManagedDatabaseQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
const cpuQty = this.parseCpuToCores(dto.cpuLimit || '500m');
const memoryQty = this.parseMemoryToGb(dto.memoryLimit || '512Mi');
const storageQty = dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
: 1;
const map = new Map<PricingResourceType, number>();
map.set(PricingResourceType.DATABASE_ADDON, 1);
map.set(PricingResourceType.CPU_PER_CORE, cpuQty);
map.set(PricingResourceType.MEMORY_PER_GB, memoryQty);
map.set(PricingResourceType.STORAGE_PER_GB, storageQty);
return map;
}
private buildOptionalServiceLines(
dto: CalculateCostDto,
optional: OptionalBillingContext,
@@ -589,7 +649,10 @@ export class PricingCatalogService implements OnModuleInit {
}
getQuantities(dto: CalculateCostDto): Map<PricingResourceType, number> {
const replicas = dto.replicas || 1;
if ((dto.productType ?? ProductType.APPLICATION) === ProductType.MANAGED_DATABASE) {
return this.getManagedDatabaseQuantities(dto);
}
const replicas = dto.replicas ?? 1;
const hasDatabase = dto.databaseType !== DatabaseType.NONE && dto.databaseType !== 'none';
const cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
const memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
+19
View File
@@ -44,6 +44,25 @@ export enum DatabaseType {
NONE = 'none',
}
/** Standalone managed offerings vs full application deploy. */
export enum ProductType {
APPLICATION = 'application',
MANAGED_DATABASE = 'managed_database',
MANAGED_REDIS = 'managed_redis',
MANAGED_RABBITMQ = 'managed_rabbitmq',
}
export function isManagedProductType(productType?: ProductType | string): boolean {
return (
productType === ProductType.MANAGED_DATABASE ||
productType === ProductType.MANAGED_REDIS ||
productType === ProductType.MANAGED_RABBITMQ
);
}
/** Stored in latestImageTag after a managed service is provisioned via Helm (no app image build). */
export const MANAGED_DEPLOY_MARKER = 'helm-managed';
// Optional services that can be attached to an application
export enum OptionalService {
REDIS = 'redis',
+5
View File
@@ -43,6 +43,11 @@ export default () => ({
platform: {
domain: process.env.PLATFORM_DOMAIN || 'apps.cloudhost.local',
uploadDir: process.env.UPLOAD_DIR || './uploads',
/** StorageClass for new PVCs; must support allowVolumeExpansion for disk resize */
storageClass: process.env.PLATFORM_STORAGE_CLASS || 'cloudhost-expandable',
/** Install cloudhost-expandable StorageClass via Helm on each app deploy */
createStorageClass: process.env.PLATFORM_CREATE_STORAGE_CLASS !== 'false',
storageProvisioner: process.env.PLATFORM_STORAGE_PROVISIONER || 'rancher.io/local-path',
},
// Lifecycle defaults (can be overridden via PlatformSettings entity by admin)
+204 -11
View File
@@ -6,7 +6,12 @@ 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 { AppLifecycleStatus, DeploymentStatus } from '../common/enums';
import {
AppLifecycleStatus,
DeploymentStatus,
isManagedProductType,
MANAGED_DEPLOY_MARKER,
} from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
@Injectable()
@@ -36,22 +41,111 @@ export class DeploymentsService {
});
const saved = await this.deploymentsRepository.save(deployment);
// Trigger async build & deploy pipeline
this.executePipeline(saved.id, app).catch((error) => {
// 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);
run.catch((error) => {
this.logger.error(`Pipeline failed for deployment ${saved.id}:`, error);
});
return saved;
}
/** Provision managed database/redis/rabbitmq via Helm only — no image build. */
private async executeManagedPipeline(deploymentId: string, app: any): Promise<void> {
try {
await this.updateStatus(deploymentId, DeploymentStatus.DEPLOYING);
this.buildService.setProgress(deploymentId, {
phase: 'deploying',
percent: 10,
message: 'Provisioning service via Helm...',
});
const hasDbDump = app.dbDumpPath && fs.existsSync(app.dbDumpPath);
const { app: deployedApp, k8sResources } = await this.deployManagedWithClusterFallback(
deploymentId,
app,
hasDbDump,
);
app = deployedApp;
if (hasDbDump) {
this.logger.log(`Restoring DB dump for ${app.name} from ${app.dbDumpPath}`);
try {
await this.kubernetesService.waitForDatabaseReady(app, 120_000);
const freshApp = await this.applicationsService.findOne(app.id);
const result = await this.kubernetesService.restoreDatabaseDump(freshApp, freshApp.dbDumpPath!);
if (result.success) {
this.logger.log(`DB dump restored successfully for ${app.name}`);
} else {
this.logger.warn(`DB dump restore failed for ${app.name}: ${result.logs}`);
}
} catch (e: any) {
this.logger.warn(`DB dump restore error for ${app.name}: ${e.message}`);
}
}
this.buildService.setProgress(deploymentId, {
phase: 'deploying',
percent: 96,
message: 'Waiting for service pods to become ready...',
});
await this.kubernetesService.waitForApplicationReady(
app,
600_000,
() => this.isDeploymentCancelled(deploymentId),
);
if (await this.isDeploymentCancelled(deploymentId)) {
return;
}
await this.applicationsService.updateImageTag(app.id, MANAGED_DEPLOY_MARKER);
this.buildService.setProgress(deploymentId, {
phase: 'done',
percent: 100,
message: 'Service provisioned',
});
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.RUNNING,
k8sResources,
finishedAt: new Date(),
});
} catch (error: any) {
if (this.isCancellationError(error) || (await this.isDeploymentCancelled(deploymentId))) {
this.logger.log(`Deployment ${deploymentId} cancelled by user`);
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.CANCELLED,
errorMessage: 'Cancelled by user',
finishedAt: new Date(),
});
return;
}
this.logger.error(`Managed deployment ${deploymentId} failed:`, error);
this.buildService.setProgress(deploymentId, {
phase: 'failed',
percent: 0,
message: error.message || 'Provisioning failed',
});
await this.deploymentsRepository.update(deploymentId, {
status: DeploymentStatus.FAILED,
errorMessage: error.message,
finishedAt: new Date(),
});
}
}
private async executePipeline(deploymentId: string, app: any): Promise<void> {
try {
// Step 1: Build image
await this.updateStatus(deploymentId, DeploymentStatus.BUILDING);
const { imageUri, buildLog } = await this.buildService.buildImage(app, deploymentId);
const buildResult = await this.buildService.buildImage(app, deploymentId);
const imageUri = buildResult.imageUri;
// Save build log
await this.deploymentsRepository.update(deploymentId, { buildLog });
await this.deploymentsRepository.update(deploymentId, { buildLog: buildResult.buildLog });
// Step 2: Update app with new image tag
await this.applicationsService.updateImageTag(app.id, imageUri);
@@ -149,6 +243,74 @@ export class DeploymentsService {
}
}
private async deployManagedWithClusterFallback(
deploymentId: string,
app: any,
hasDbDump: boolean,
): Promise<{ app: any; k8sResources: Record<string, any> }> {
const failedClusterIds: string[] = [];
let currentApp = app;
let lastError: any;
const maxAttempts = Number(process.env.CLUSTER_DEPLOY_FALLBACK_ATTEMPTS || 3);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
if (await this.isDeploymentCancelled(deploymentId)) {
throw new Error('Deployment cancelled by user');
}
try {
this.buildService.setProgress(deploymentId, {
phase: 'deploying',
percent: Math.min(20 + attempt * 5, 90),
message:
attempt === 1
? 'Installing Helm release...'
: `Retrying Helm install on fallback cluster (${attempt}/${maxAttempts})...`,
});
const k8sResources = await this.kubernetesService.deployManagedService(currentApp);
return { app: currentApp, k8sResources };
} catch (error: any) {
lastError = error;
failedClusterIds.push(currentApp.clusterId);
const failureMessage = error?.message || 'Helm provisioning failed on selected cluster';
await this.clustersService.markAllocationFailure(currentApp.id, currentApp.clusterId, failureMessage);
if (attempt >= maxAttempts) {
break;
}
try {
const fallback = await this.clustersService.chooseFallbackClusterForApplication(
currentApp,
failedClusterIds,
failureMessage,
);
const updatedApp = await this.applicationsService.updateClusterAssignment(
currentApp.id,
fallback.cluster.id,
fallback.pool?.id,
);
await this.clustersService.attachAllocationToApplication(fallback.allocationLogId, currentApp.id);
this.logger.warn(
`Managed deployment ${deploymentId} falling back from cluster ${currentApp.clusterId || 'none'} to ${fallback.cluster.id}`,
);
currentApp = {
...currentApp,
...updatedApp,
clusterId: fallback.cluster.id,
poolId: fallback.pool?.id || currentApp.poolId,
};
} catch (fallbackError: any) {
lastError = fallbackError;
break;
}
}
}
throw lastError || new Error('Managed service provisioning failed');
}
private async deployWithClusterFallback(
deploymentId: string,
app: any,
@@ -174,7 +336,10 @@ export class DeploymentsService {
: `Retrying deployment on fallback cluster (${attempt}/${maxAttempts})...`,
});
const deployApp = hasDbDump ? { ...currentApp, replicas: 0 } : currentApp;
const deployApp =
hasDbDump && !isManagedProductType(currentApp.productType)
? { ...currentApp, replicas: 0 }
: currentApp;
const k8sResources = await this.kubernetesService.deployApplication(deployApp, imageUri);
return { app: currentApp, k8sResources };
} catch (error: any) {
@@ -231,6 +396,12 @@ export class DeploymentsService {
String(error?.message || '').toLowerCase().includes('cancelled');
}
/** Managed DB/Redis/RabbitMQ or rows already provisioned via Helm without an app image build. */
private isManagedOrHelmOnlyApp(app: { productType?: string; latestImageTag?: string }): boolean {
if (isManagedProductType(app.productType)) return true;
return app.latestImageTag === MANAGED_DEPLOY_MARKER;
}
private ensureRedeployAllowed(app: any): void {
if (!app.billingCycle) return;
@@ -267,8 +438,7 @@ export class DeploymentsService {
}
async getBuildLogs(applicationId: string, userId: string): Promise<{ buildLog: string | null; status: string; version: string | null; createdAt: Date }> {
// Verify user access
await this.applicationsService.findOne(applicationId, userId);
const app = await this.applicationsService.findOne(applicationId, userId);
const latest = await this.deploymentsRepository.findOne({
where: { applicationId },
@@ -279,6 +449,15 @@ export class DeploymentsService {
return { buildLog: null, status: 'no_deployment', version: null, createdAt: new Date() };
}
if (this.isManagedOrHelmOnlyApp(app)) {
return {
buildLog: null,
status: latest.status,
version: latest.version,
createdAt: latest.createdAt,
};
}
return {
buildLog: latest.buildLog || null,
status: latest.status,
@@ -288,7 +467,8 @@ export class DeploymentsService {
}
async getBuildProgress(applicationId: string, userId: string): Promise<BuildProgress | null> {
await this.applicationsService.findOne(applicationId, userId);
const app = await this.applicationsService.findOne(applicationId, userId);
const managed = this.isManagedOrHelmOnlyApp(app);
const latest = await this.deploymentsRepository.findOne({
where: { applicationId },
@@ -311,10 +491,18 @@ export class DeploymentsService {
return { phase: 'cancelled', percent: 0, message: latest.errorMessage || 'Cancelled by user' };
}
if (latest.status === DeploymentStatus.BUILDING) {
return { phase: 'building', percent: 0, message: 'Building...' };
return {
phase: managed ? 'deploying' : 'building',
percent: 0,
message: managed ? 'Provisioning...' : 'Building...',
};
}
if (latest.status === DeploymentStatus.DEPLOYING) {
return { phase: 'deploying', percent: 90, message: 'Deploying...' };
return {
phase: 'deploying',
percent: 90,
message: managed ? 'Provisioning via Helm...' : 'Deploying...',
};
}
return null;
}
@@ -412,6 +600,11 @@ export class DeploymentsService {
async redeployApplication(applicationId: string, userId: string): Promise<Deployment> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (this.isManagedOrHelmOnlyApp(app)) {
this.logger.log(`Re-provisioning ${app.name} via Helm (no build)`);
return this.triggerDeployment(applicationId, userId);
}
if (!app.codePath && !app.gitUrl) {
throw new NotFoundException('No source code available. Upload code or set a git URL first.');
}
+568 -46
View File
@@ -9,7 +9,14 @@ import { PassThrough } from 'stream';
import { ClustersService } from '../clusters/clusters.service';
import { Application } from '../applications/entities/application.entity';
import { ensureAppUrlEnv } from '../applications/app-url.util';
import { AppRuntime, DatabaseType, CustomDomainStatus, ServiceAccessTarget } from '../common/enums';
import {
AppRuntime,
DatabaseType,
CustomDomainStatus,
ServiceAccessTarget,
ProductType,
isManagedProductType,
} from '../common/enums';
import { HelmService } from './helm.service';
import { registerKubeconfigNoProxy } from '../common/kubernetes-proxy.util';
@@ -111,6 +118,7 @@ export class KubernetesService implements OnModuleInit {
const storageGi = res?.storageGi ?? 1;
return {
enabled: app.enableRedis || false,
version: app.redisVersion || '7.2',
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '50m',
@@ -126,6 +134,7 @@ export class KubernetesService implements OnModuleInit {
const storageGi = res?.storageGi ?? 2;
return {
enabled: app.enableRabbitmq || false,
version: app.rabbitmqVersion || '3.13',
storageSize: `${storageGi}Gi`,
resources: {
cpuRequest: res?.cpuRequest || '100m',
@@ -141,6 +150,98 @@ export class KubernetesService implements OnModuleInit {
return ensureAppUrlEnv(app, platformDomain);
}
private helmGlobalStorageValues(): Record<string, unknown> {
const storageClass = this.configService.get<string>('platform.storageClass') || '';
const createStorageClass = this.configService.get<boolean>('platform.createStorageClass') === true;
const storageProvisioner =
this.configService.get<string>('platform.storageProvisioner') || 'rancher.io/local-path';
if (!storageClass) {
return { storageClass: '', createStorageClass: false, storageProvisioner };
}
return { storageClass, createStorageClass, storageProvisioner };
}
/** Helm values for managed_database / managed_redis / managed_rabbitmq (no app workload). */
private buildManagedHelmValues(app: Application): Record<string, any> {
const namespace = `user-${app.userId.split('-')[0]}`;
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const productType = app.productType;
const values: Record<string, any> = {
global: this.helmGlobalStorageValues(),
app: {
enabled: false,
name: app.name,
namespace,
runtime: app.runtime,
image: '',
port: app.port || 3000,
replicas: 0,
storageSize: app.appStorageSize || '2Gi',
},
resources: {
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
},
envVars: {},
ingress: {
enabled: false,
subdomain: app.subdomain || app.name,
domain: this.configService.get('platform.domain'),
clusterIssuer: 'letsencrypt-prod',
customDomain: '',
},
registry: { url: pullRegistryUrl },
database: {
enabled: false,
type: app.databaseType,
version: app.dbVersion || (isPostgres ? '16' : '8.0'),
username: app.dbUsername || 'appuser',
password: app.dbPassword || this.generatePassword(),
storageSize: app.dbStorageSize || '1Gi',
resources: {
cpuRequest: app.cpuRequest || '100m',
cpuLimit: app.cpuLimit || '500m',
memoryRequest: app.memoryRequest || '256Mi',
memoryLimit: app.memoryLimit || '512Mi',
},
},
redis: { enabled: false, storageSize: '1Gi', resources: {} },
rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} },
wordpress: { enabled: false },
elasticsearch: {
enabled: false,
logPaths: [],
ownerId: app.userId,
applicationId: app.id,
},
changeCause: `Helm provision ${app.name} (${productType}) at ${new Date().toISOString()}`,
};
switch (productType) {
case ProductType.MANAGED_DATABASE:
values.database.enabled = true;
values.redis.enabled = false;
values.rabbitmq.enabled = false;
break;
case ProductType.MANAGED_REDIS:
values.redis = this.buildRedisHelmBlock(app);
values.redis.enabled = true;
break;
case ProductType.MANAGED_RABBITMQ:
values.rabbitmq = this.buildRabbitmqHelmBlock(app);
values.rabbitmq.enabled = true;
break;
default:
break;
}
return values;
}
private buildHelmValues(app: Application, imageUri: string): Record<string, any> {
const domain = this.configService.get('platform.domain');
const pullRegistryUrl = this.configService.get<string>('registry.pullUrl') || 'localhost:30500';
@@ -149,13 +250,15 @@ export class KubernetesService implements OnModuleInit {
const isPostgres = app.databaseType === DatabaseType.POSTGRESQL;
const values: Record<string, any> = {
global: this.helmGlobalStorageValues(),
app: {
enabled: true,
name: app.name,
namespace: `user-${app.userId.split('-')[0]}`,
runtime: app.runtime,
image: imageUri,
port: app.port,
replicas: app.replicas,
replicas: app.replicas || 1,
storageSize: app.appStorageSize || '2Gi',
},
resources: {
@@ -170,9 +273,8 @@ export class KubernetesService implements OnModuleInit {
subdomain: app.subdomain || app.name,
domain: domain,
clusterIssuer: 'letsencrypt-prod',
customDomain: (app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED)
? app.customDomain
: '',
customDomain:
app.customDomain && app.customDomainStatus === CustomDomainStatus.VERIFIED ? app.customDomain : '',
},
registry: {
url: pullRegistryUrl,
@@ -185,10 +287,10 @@ export class KubernetesService implements OnModuleInit {
password: app.dbPassword || this.generatePassword(),
storageSize: app.dbStorageSize || '1Gi',
resources: {
cpuRequest: '100m',
cpuLimit: '500m',
memoryRequest: '256Mi',
memoryLimit: '512Mi',
cpuRequest: app.cpuRequest || '100m',
cpuLimit: app.cpuLimit || '500m',
memoryRequest: app.memoryRequest || '256Mi',
memoryLimit: app.memoryLimit || '512Mi',
},
},
wordpress: {
@@ -208,7 +310,25 @@ export class KubernetesService implements OnModuleInit {
return values;
}
/** Install or upgrade only the workload for a managed service (database, Redis, or RabbitMQ). */
async deployManagedService(app: Application): Promise<Record<string, any>> {
if (!isManagedProductType(app.productType)) {
throw new BadRequestException('deployManagedService requires a managed product type');
}
try {
return await this.deployManagedViaHelm(app);
} catch (helmError: any) {
this.logger.warn(
`Helm provision failed for managed ${app.name}, falling back to direct K8s API: ${helmError.message}`,
);
return await this.deployManagedViaK8sApi(app);
}
}
async deployApplication(app: Application, imageUri: string): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) {
return this.deployManagedService(app);
}
// Try Helm first, fall back to direct K8s API if Helm is unavailable
try {
return await this.deployViaHelm(app, imageUri);
@@ -227,8 +347,9 @@ export class KubernetesService implements OnModuleInit {
): Promise<void> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const managed = isManagedProductType(app.productType);
const workloads = [
{ name: app.name, replicas: app.replicas || 1 },
...(!managed ? [{ name: app.name, replicas: app.replicas || 1 }] : []),
...(app.databaseType !== DatabaseType.NONE ? [{ name: `${app.name}-db`, replicas: 1 }] : []),
...(app.enableRedis ? [{ name: `${app.name}-redis`, replicas: 1 }] : []),
...(app.enableRabbitmq ? [{ name: `${app.name}-rabbitmq`, replicas: 1 }] : []),
@@ -337,9 +458,90 @@ export class KubernetesService implements OnModuleInit {
return { helm: { release: releaseName, namespace, stdout: result.stdout }, values };
}
private async deployManagedViaHelm(app: Application): Promise<Record<string, any>> {
const kubeconfig = await this.getKubeconfig(app.clusterId);
const values = this.buildManagedHelmValues(app);
const namespace = values.app.namespace;
const releaseName = app.name;
const result = await this.helmService.installOrUpgrade(
releaseName,
namespace,
values,
kubeconfig,
);
this.logger.log(`Successfully provisioned managed service ${app.name} in ${namespace} via Helm`);
return { helm: { release: releaseName, namespace, stdout: result.stdout }, values };
}
// ── Direct K8s API deployment (fallback) ──────────────────────────
private async deployManagedViaK8sApi(app: Application): Promise<Record<string, any>> {
const { coreApi, appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const context: ManifestContext = {
appName: app.name,
namespace,
image: '',
port: app.port || 3000,
replicas: 0,
cpuRequest: app.cpuRequest,
cpuLimit: app.cpuLimit,
memoryRequest: app.memoryRequest,
memoryLimit: app.memoryLimit,
envVars: {},
runtime: app.runtime,
databaseType: app.databaseType,
domain: this.configService.get('platform.domain') || 'apps.cloudhost.ir',
subdomain: app.subdomain || app.name,
dbUsername: app.dbUsername || 'appuser',
dbPassword: app.dbPassword || this.generatePassword(),
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: false,
redisVersion: app.redisVersion || '7.2',
enableRabbitmq: false,
rabbitmqVersion: app.rabbitmqVersion || '3.13',
enableElasticsearch: false,
elasticsearchVersion: app.elasticsearchVersion || '8.12',
logPaths: [],
ownerId: app.userId,
applicationId: app.id,
};
const manifests: Record<string, any> = {};
await this.ensureNamespace(coreApi, namespace);
switch (app.productType) {
case ProductType.MANAGED_DATABASE:
context.databaseType = app.databaseType;
manifests.database = await this.deployDatabase(coreApi, appsApi, context);
break;
case ProductType.MANAGED_REDIS:
context.enableRedis = true;
await this.deployRedis(coreApi, appsApi, context);
manifests.redis = true;
break;
case ProductType.MANAGED_RABBITMQ:
context.enableRabbitmq = true;
await this.deployRabbitmq(coreApi, appsApi, context);
manifests.rabbitmq = true;
break;
default:
throw new BadRequestException(`Unsupported managed product type: ${app.productType}`);
}
this.logger.log(`Provisioned managed service ${app.name} in ${namespace} via K8s API`);
return manifests;
}
private async deployViaK8sApi(app: Application, imageUri: string): Promise<Record<string, any>> {
if (isManagedProductType(app.productType)) {
return this.deployManagedViaK8sApi(app);
}
const { coreApi, appsApi, networkingApi } = await this.getK8sClient(app.clusterId);
const domain = this.configService.get('platform.domain');
@@ -600,6 +802,9 @@ export class KubernetesService implements OnModuleInit {
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: ctx.appStorageSize || '2Gi' } },
...(this.configService.get<string>('platform.storageClass')
? { storageClassName: this.configService.get<string>('platform.storageClass') }
: {}),
},
};
@@ -1098,20 +1303,23 @@ export class KubernetesService implements OnModuleInit {
let livenessProbe: any;
switch (dbType) {
case DatabaseType.POSTGRESQL:
case DatabaseType.POSTGRESQL: {
const pgVer = ctx.dbVersion || '16';
const pgDatabase = ctx.appName.replace(/-/g, '_');
image = `postgres:${pgVer}-alpine`;
port = 5432;
dataPath = '/var/lib/postgresql/data';
envVars = [
{ name: 'PGDATA', value: '/var/lib/postgresql/data/pgdata' },
{ name: 'POSTGRES_DB', value: ctx.appName.replace(/-/g, '_') },
{ name: 'POSTGRES_DB', value: pgDatabase },
{ name: 'POSTGRES_USER', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'username' } } },
{ name: 'POSTGRES_PASSWORD', valueFrom: { secretKeyRef: { name: `${ctx.appName}-db-secret`, key: 'password' } } },
];
readinessProbe = { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 };
livenessProbe = { exec: { command: ['pg_isready', '-U', ctx.dbUsername] }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 };
const pgReady = ['pg_isready', '-U', ctx.dbUsername, '-d', pgDatabase];
readinessProbe = { exec: { command: pgReady }, initialDelaySeconds: 10, periodSeconds: 5, failureThreshold: 6 };
livenessProbe = { exec: { command: pgReady }, initialDelaySeconds: 30, periodSeconds: 10, failureThreshold: 5 };
break;
}
case DatabaseType.MYSQL:
const mysqlVer = ctx.dbVersion || '8.0';
@@ -1255,6 +1463,7 @@ export class KubernetesService implements OnModuleInit {
name: string,
size: string,
): Promise<void> {
const storageClass = this.configService.get<string>('platform.storageClass');
const pvc: k8s.V1PersistentVolumeClaim = {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
@@ -1262,6 +1471,7 @@ export class KubernetesService implements OnModuleInit {
spec: {
accessModes: ['ReadWriteOnce'],
resources: { requests: { storage: size } },
...(storageClass ? { storageClassName: storageClass } : {}),
},
};
@@ -1520,9 +1730,26 @@ export class KubernetesService implements OnModuleInit {
this.logger.log(`RabbitMQ deployed for ${ctx.appName}`);
}
private primaryWorkloadLabel(app: Application): string {
if (isManagedProductType(app.productType)) {
switch (app.productType) {
case ProductType.MANAGED_DATABASE:
return `${app.name}-db`;
case ProductType.MANAGED_REDIS:
return `${app.name}-redis`;
case ProductType.MANAGED_RABBITMQ:
return `${app.name}-rabbitmq`;
default:
break;
}
}
return app.name;
}
async getPodLogs(app: Application): Promise<string> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const podLabel = this.primaryWorkloadLabel(app);
const pods = await coreApi.listNamespacedPod(
namespace,
@@ -1530,7 +1757,7 @@ export class KubernetesService implements OnModuleInit {
undefined,
undefined,
undefined,
`app=${app.name}`,
`app=${podLabel}`,
);
if (pods.body.items.length === 0) {
@@ -1575,9 +1802,12 @@ export class KubernetesService implements OnModuleInit {
/** All K8s Deployments that belong to an application stack (default replica targets). */
private getApplicationWorkloadDeployments(app: Application): { name: string; runningReplicas: number }[] {
const workloads: { name: string; runningReplicas: number }[] = [
{ name: app.name, runningReplicas: app.replicas || 1 },
];
const managed = isManagedProductType(app.productType);
const workloads: { name: string; runningReplicas: number }[] = [];
if (!managed) {
workloads.push({ name: app.name, runningReplicas: app.replicas || 1 });
}
if (app.databaseType && app.databaseType !== DatabaseType.NONE) {
workloads.push({ name: `${app.name}-db`, runningReplicas: 1 });
@@ -1705,9 +1935,12 @@ export class KubernetesService implements OnModuleInit {
async restartDeployment(app: Application): Promise<void> {
const { appsApi } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const deploymentName = isManagedProductType(app.productType)
? this.primaryWorkloadLabel(app)
: app.name;
await appsApi.patchNamespacedDeployment(
app.name,
deploymentName,
namespace,
{
spec: {
@@ -2965,50 +3198,331 @@ export class KubernetesService implements OnModuleInit {
return { success: true, logs };
}
private isPvcResizeForbiddenError(err: unknown): boolean {
const msg =
(err as { body?: { message?: string }; message?: string })?.body?.message ||
(err as Error)?.message ||
'';
return /forbidden|resize|storageclass|dynamically provisioned/i.test(msg);
}
private async resolvePvcStorageClassName(
coreApi: k8s.CoreV1Api,
pvc: k8s.V1PersistentVolumeClaim,
): Promise<string | undefined> {
let scName = pvc.spec?.storageClassName;
if (scName) return scName;
const volumeName = pvc.spec?.volumeName;
if (!volumeName) return undefined;
try {
const pv = await coreApi.readPersistentVolume(volumeName);
scName = pv.body.spec?.storageClassName;
if (pv.body.spec?.hostPath || pv.body.spec?.nfs || pv.body.spec?.local) {
return undefined;
}
return scName;
} catch {
return undefined;
}
}
private async ensureStorageClassAllowsExpansion(
kc: k8s.KubeConfig,
storageClassName: string,
): Promise<{ ok: boolean; message?: string }> {
const storageApi = kc.makeApiClient(k8s.StorageV1Api);
try {
const sc = await storageApi.readStorageClass(storageClassName);
if (sc.body.allowVolumeExpansion) {
return { ok: true };
}
await storageApi.patchStorageClass(
storageClassName,
{ allowVolumeExpansion: true },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
this.logger.log(`Enabled allowVolumeExpansion on StorageClass ${storageClassName}`);
return { ok: true };
} catch (e: any) {
return {
ok: false,
message: `StorageClass "${storageClassName}" does not support expansion: ${e.message}`,
};
}
}
private async patchPvcStorageSize(
coreApi: k8s.CoreV1Api,
pvcName: string,
namespace: string,
newSize: string,
): Promise<void> {
await coreApi.patchNamespacedPersistentVolumeClaim(
pvcName,
namespace,
[{ op: 'replace', path: '/spec/resources/requests/storage', value: newSize }],
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/json-patch+json' } },
);
}
/**
* Migrate DB PVC to a resize-capable StorageClass (one-time copy).
* Used when legacy PVCs were created without storageClassName.
*/
private async migrateDatabasePvcToResizableStorage(
app: Application,
newSize: string,
storageClassName: string,
): Promise<{ success: boolean; message: string }> {
const { coreApi, appsApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
const oldPvcName = `${app.name}-db`;
const newPvcName = `${app.name}-db-resizable`;
const deploymentName = `${app.name}-db`;
try {
try {
await coreApi.readNamespacedPersistentVolumeClaim(newPvcName, namespace);
} catch {
await coreApi.createNamespacedPersistentVolumeClaim(namespace, {
apiVersion: 'v1',
kind: 'PersistentVolumeClaim',
metadata: { name: newPvcName, namespace },
spec: {
accessModes: ['ReadWriteOnce'],
storageClassName,
resources: { requests: { storage: newSize } },
},
});
}
await appsApi.patchNamespacedDeployment(
deploymentName,
namespace,
{ spec: { replicas: 0 } },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
await this.waitForDeploymentReplicas(appsApi, namespace, deploymentName, 0, 120_000);
const jobName = `${app.name}-pvc-migrate-${Date.now()}`;
await batchApi.createNamespacedJob(namespace, {
apiVersion: 'batch/v1',
kind: 'Job',
metadata: { name: jobName, namespace },
spec: {
ttlSecondsAfterFinished: 300,
backoffLimit: 1,
template: {
spec: {
restartPolicy: 'Never',
containers: [
{
name: 'copy',
image: 'busybox:1.36',
command: [
'sh',
'-c',
'set -e; mkdir -p /dest; if [ -d /src ] && [ "$(ls -A /src 2>/dev/null)" ]; then cp -a /src/. /dest/; fi; touch /dest/.cloudhost-migrated',
],
volumeMounts: [
{ name: 'src', mountPath: '/src', readOnly: true },
{ name: 'dest', mountPath: '/dest' },
],
},
],
volumes: [
{ name: 'src', persistentVolumeClaim: { claimName: oldPvcName } },
{ name: 'dest', persistentVolumeClaim: { claimName: newPvcName } },
],
},
},
},
});
const jobOk = await this.waitForJobComplete(batchApi, coreApi, namespace, jobName, 600_000);
if (!jobOk) {
return {
success: false,
message: 'Storage migration job failed or timed out. Database was scaled down; check cluster jobs.',
};
}
const depRes = await appsApi.readNamespacedDeployment(deploymentName, namespace);
const existingVolumes = depRes.body.spec?.template?.spec?.volumes || [];
const updatedVolumes = existingVolumes.map((vol) => {
if (vol.name === 'db-storage' && vol.persistentVolumeClaim) {
return { ...vol, persistentVolumeClaim: { claimName: newPvcName } };
}
return vol;
});
if (!updatedVolumes.some((v) => v.name === 'db-storage')) {
updatedVolumes.push({
name: 'db-storage',
persistentVolumeClaim: { claimName: newPvcName },
});
}
await appsApi.patchNamespacedDeployment(
deploymentName,
namespace,
{
spec: {
replicas: 1,
template: { spec: { volumes: updatedVolumes } },
},
},
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
try {
await coreApi.deleteNamespacedPersistentVolumeClaim(oldPvcName, namespace);
} catch {
this.logger.warn(`Could not delete old PVC ${oldPvcName} after migration`);
}
this.logger.log(`Migrated database PVC ${oldPvcName}${newPvcName} (${newSize})`);
return {
success: true,
message: `Database storage migrated to expandable disk and set to ${newSize}. A brief restart was required.`,
};
} catch (e: any) {
this.logger.error(`PVC migration failed for ${app.name}: ${e.message}`);
try {
await appsApi.patchNamespacedDeployment(
deploymentName,
namespace,
{ spec: { replicas: 1 } },
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/strategic-merge-patch+json' } },
);
} catch {}
return {
success: false,
message: e.body?.message || e.message || 'Failed to migrate database storage',
};
}
}
private async waitForDeploymentReplicas(
appsApi: k8s.AppsV1Api,
namespace: string,
name: string,
target: number,
timeoutMs: number,
): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const dep = await appsApi.readNamespacedDeployment(name, namespace);
const ready = dep.body.status?.readyReplicas ?? 0;
const replicas = dep.body.spec?.replicas ?? 0;
if (target === 0 && replicas === 0) return true;
if (target > 0 && ready >= target && replicas >= target) return true;
} catch {}
await new Promise((r) => setTimeout(r, 3000));
}
return false;
}
private async waitForJobComplete(
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
namespace: string,
jobName: string,
timeoutMs: number,
): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const job = await batchApi.readNamespacedJob(jobName, namespace);
const succeeded = job.body.status?.succeeded ?? 0;
const failed = job.body.status?.failed ?? 0;
if (succeeded > 0) return true;
if (failed > 0) return false;
} catch {}
await new Promise((r) => setTimeout(r, 3000));
}
return false;
}
/**
* Resize (expand) the database PVC for an application.
* K8s only supports PVC expansion, not shrinking.
*/
async resizeDatabasePvc(app: Application, newSize: string): Promise<{ success: boolean; message: string }> {
const { coreApi } = await this.getK8sClient(app.clusterId);
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const namespace = `user-${app.userId.split('-')[0]}`;
const pvcName = `${app.name}-db`;
try {
// Read current PVC to check current size
const currentPvc = await coreApi.readNamespacedPersistentVolumeClaim(pvcName, namespace);
const currentSize = currentPvc.body.spec?.resources?.requests?.storage || '1Gi';
const currentGi = parseInt(currentSize.replace('Gi', ''), 10) || 1;
const newGi = parseInt(newSize.replace('Gi', ''), 10) || 1;
const currentGi = parseInt(String(currentSize).replace(/Gi/i, ''), 10) || 1;
const newGi = parseInt(String(newSize).replace(/Gi/i, ''), 10) || 1;
if (newGi <= currentGi) {
return { success: false, message: `New size (${newSize}) must be larger than current size (${currentSize})` };
}
// Patch PVC to expand
const patch = [
{
op: 'replace',
path: '/spec/resources/requests/storage',
value: newSize,
},
];
const scName = await this.resolvePvcStorageClassName(coreApi, currentPvc.body);
if (scName) {
const scCheck = await this.ensureStorageClassAllowsExpansion(kc, scName);
if (!scCheck.ok) {
return { success: false, message: scCheck.message || 'StorageClass does not allow expansion' };
}
}
await coreApi.patchNamespacedPersistentVolumeClaim(
pvcName,
namespace,
patch,
undefined,
undefined,
undefined,
undefined,
undefined,
{ headers: { 'Content-Type': 'application/json-patch+json' } },
);
this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`);
return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` };
try {
await this.patchPvcStorageSize(coreApi, pvcName, namespace, newSize);
this.logger.log(`Resized PVC ${pvcName} from ${currentSize} to ${newSize}`);
return { success: true, message: `Database storage expanded from ${currentSize} to ${newSize}` };
} catch (patchErr: any) {
if (!this.isPvcResizeForbiddenError(patchErr)) {
throw patchErr;
}
const targetSc = this.configService.get<string>('platform.storageClass');
if (!targetSc) {
return {
success: false,
message:
'This disk cannot be expanded in place. Set PLATFORM_STORAGE_CLASS (e.g. cloudhost-expandable) and redeploy, or contact support.',
};
}
if (pvcName.endsWith('-resizable')) {
return {
success: false,
message: patchErr.body?.message || patchErr.message || 'Failed to resize database storage',
};
}
this.logger.warn(`In-place resize failed for ${pvcName}, migrating to StorageClass ${targetSc}`);
return this.migrateDatabasePvcToResizableStorage(app, newSize, targetSc);
}
} catch (e: any) {
this.logger.error(`Failed to resize PVC ${pvcName}: ${e.message}`);
return { success: false, message: e.body?.message || e.message || 'Failed to resize database storage' };
@@ -3297,7 +3811,10 @@ export class KubernetesService implements OnModuleInit {
*
* Strategy: Run dump command, then sleep for 60s to allow exec retrieval.
*/
async exportDatabaseDump(app: Application): Promise<{ data: Buffer | null; logs: string }> {
async exportDatabaseDump(
app: Application,
onProgress?: (percent: number) => void,
): Promise<{ data: Buffer | null; logs: string }> {
const { coreApi, kc } = await this.getK8sClient(app.clusterId);
const batchApi = kc.makeApiClient(k8s.BatchV1Api);
const namespace = `user-${app.userId.split('-')[0]}`;
@@ -3358,6 +3875,9 @@ export class KubernetesService implements OnModuleInit {
while (Date.now() - start < timeout) {
await new Promise((r) => setTimeout(r, 3000));
const elapsed = Date.now() - start;
const waitPct = Math.min(75, Math.round((elapsed / timeout) * 75));
onProgress?.(10 + waitPct);
try {
const pods = await coreApi.listNamespacedPod(namespace, undefined, undefined, undefined, undefined, `job-name=${jobName}`);
if (pods.body.items.length > 0) {
@@ -3370,6 +3890,7 @@ export class KubernetesService implements OnModuleInit {
const logRes = await coreApi.readNamespacedPodLog(podName, namespace, 'dump', false, undefined, undefined, undefined, undefined, undefined, 50);
if (logRes.body?.includes('DUMP_DONE')) {
dumpDone = true;
onProgress?.(88);
break;
}
} catch {}
@@ -3418,6 +3939,7 @@ export class KubernetesService implements OnModuleInit {
if (chunks.length > 0) {
dumpBuffer = Buffer.concat(chunks);
onProgress?.(95);
this.logger.log(`DB dump retrieved: ${dumpBuffer.length} bytes`);
}
} catch (e: any) {
@@ -63,6 +63,10 @@ export class AppSnapshot {
@Column({ nullable: true })
errorMessage: string;
/** 0100 while status is in_progress */
@Column({ type: 'int', default: 0 })
progress: number;
// ─── Relations ────────────────────────────────────
@ManyToOne(() => Application, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'applicationId' })
+88 -26
View File
@@ -1,4 +1,12 @@
import { Injectable, Logger, NotFoundException, BadRequestException, Inject, forwardRef } from '@nestjs/common';
import {
Injectable,
Logger,
NotFoundException,
BadRequestException,
Inject,
forwardRef,
OnModuleInit,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
@@ -7,12 +15,12 @@ import * as path from 'path';
import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { AppRuntime, DatabaseType } from '../common/enums';
import { AppRuntime, DatabaseType, ProductType } from '../common/enums';
const MAX_SNAPSHOTS = 10;
@Injectable()
export class SnapshotsService {
export class SnapshotsService implements OnModuleInit {
private readonly logger = new Logger(SnapshotsService.name);
constructor(
@@ -24,6 +32,16 @@ export class SnapshotsService {
private configService: ConfigService,
) {}
async onModuleInit(): Promise<void> {
try {
await this.snapshotsRepo.query(
`ALTER TABLE snapshots ADD COLUMN IF NOT EXISTS progress INT NOT NULL DEFAULT 0`,
);
} catch (e: any) {
this.logger.warn(`Could not ensure snapshots.progress column: ${e.message}`);
}
}
/**
* Create a snapshot of the current state of an application.
* Captures: source code zip, wp-content (for WordPress), and DB dump.
@@ -41,6 +59,7 @@ export class SnapshotsService {
createdBy: userId,
type,
status: SnapshotStatus.IN_PROGRESS,
progress: 0,
label: label || `Snapshot ${new Date().toLocaleString()}`,
imageTag: app.latestImageTag || undefined,
hasDatabase: app.databaseType !== DatabaseType.NONE,
@@ -80,46 +99,71 @@ export class SnapshotsService {
return saved;
}
private async setSnapshotProgress(snapshotId: string, progress: number): Promise<void> {
try {
await this.snapshotsRepo.update(snapshotId, {
progress: Math.min(100, Math.max(0, progress)),
});
} catch (e: any) {
this.logger.debug(`Snapshot progress update skipped: ${e.message}`);
}
}
private async captureSnapshot(snapshotId: string, app: any): Promise<void> {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const snapshotDir = path.join(uploadDir, app.userId, app.id, 'snapshots', snapshotId);
fs.mkdirSync(snapshotDir, { recursive: true });
const updates: Partial<AppSnapshot> = {};
const managedDbOnly = app.productType === ProductType.MANAGED_DATABASE;
try {
// 1. Copy current source code zip
if (app.codePath && fs.existsSync(app.codePath)) {
const destPath = path.join(snapshotDir, 'source.zip');
fs.copyFileSync(app.codePath, destPath);
updates.appArchivePath = destPath;
updates.appArchiveSize = fs.statSync(destPath).size;
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
}
await this.setSnapshotProgress(snapshotId, 5);
// 2. Archive wp-content for WordPress apps
if (app.runtime === AppRuntime.WORDPRESS) {
try {
const { data, logs } = await this.kubernetesService.archiveWpContent(app);
if (data && data.length > 0) {
const wpPath = path.join(snapshotDir, 'wp-content.tar.gz');
fs.writeFileSync(wpPath, data);
updates.wpContentArchivePath = wpPath;
updates.wpContentSize = data.length;
this.logger.log(`Snapshot ${snapshotId}: archived wp-content (${(data.length / 1024).toFixed(1)} KB)`);
} else {
this.logger.warn(`Snapshot ${snapshotId}: wp-content archive empty — ${logs}`);
if (!managedDbOnly) {
// 1. Copy current source code zip
if (app.codePath && fs.existsSync(app.codePath)) {
await this.setSnapshotProgress(snapshotId, 12);
const destPath = path.join(snapshotDir, 'source.zip');
fs.copyFileSync(app.codePath, destPath);
updates.appArchivePath = destPath;
updates.appArchiveSize = fs.statSync(destPath).size;
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
}
// 2. Archive wp-content for WordPress apps
if (app.runtime === AppRuntime.WORDPRESS) {
await this.setSnapshotProgress(snapshotId, 18);
try {
const { data, logs } = await this.kubernetesService.archiveWpContent(app);
if (data && data.length > 0) {
const wpPath = path.join(snapshotDir, 'wp-content.tar.gz');
fs.writeFileSync(wpPath, data);
updates.wpContentArchivePath = wpPath;
updates.wpContentSize = data.length;
this.logger.log(`Snapshot ${snapshotId}: archived wp-content (${(data.length / 1024).toFixed(1)} KB)`);
} else {
this.logger.warn(`Snapshot ${snapshotId}: wp-content archive empty — ${logs}`);
}
} catch (e: any) {
this.logger.warn(`Snapshot ${snapshotId}: wp-content archive failed — ${e.message}`);
}
} catch (e: any) {
this.logger.warn(`Snapshot ${snapshotId}: wp-content archive failed — ${e.message}`);
}
}
// 3. Dump database
if (app.databaseType !== DatabaseType.NONE) {
await this.setSnapshotProgress(snapshotId, managedDbOnly ? 10 : 25);
try {
const { data, logs } = await this.kubernetesService.exportDatabaseDump(app);
const mapDumpProgress = (dumpPct: number) => {
const base = managedDbOnly ? 10 : 25;
const end = managedDbOnly ? 95 : 90;
const t = Math.min(1, Math.max(0, (dumpPct - 10) / 85));
void this.setSnapshotProgress(snapshotId, base + Math.round(t * (end - base)));
};
const { data, logs } = await this.kubernetesService.exportDatabaseDump(app, mapDumpProgress);
if (data && data.length > 0) {
await this.setSnapshotProgress(snapshotId, 92);
const dbPath = path.join(snapshotDir, 'database.sql');
fs.writeFileSync(dbPath, data);
updates.dbDumpPath = dbPath;
@@ -127,16 +171,34 @@ export class SnapshotsService {
this.logger.log(`Snapshot ${snapshotId}: dumped database (${(data.length / 1024).toFixed(1)} KB)`);
} else {
this.logger.warn(`Snapshot ${snapshotId}: DB dump empty — ${logs}`);
if (managedDbOnly) {
updates.status = SnapshotStatus.FAILED;
updates.errorMessage = logs || 'Database dump produced no data';
updates.progress = 0;
await this.snapshotsRepo.update(snapshotId, updates);
await this.pruneSnapshots(app.id);
return;
}
}
} catch (e: any) {
this.logger.warn(`Snapshot ${snapshotId}: DB dump failed — ${e.message}`);
if (managedDbOnly) {
updates.status = SnapshotStatus.FAILED;
updates.errorMessage = e.message;
updates.progress = 0;
await this.snapshotsRepo.update(snapshotId, updates);
await this.pruneSnapshots(app.id);
return;
}
}
}
updates.status = SnapshotStatus.COMPLETED;
updates.progress = 100;
} catch (error: any) {
updates.status = SnapshotStatus.FAILED;
updates.errorMessage = error.message;
updates.progress = 0;
this.logger.error(`Snapshot ${snapshotId} failed: ${error.message}`);
}