feat(deploy): configurable CPU/RAM/disk for app databases

Let users size the database workload from the deploy wizard the same way
Redis/RabbitMQ are configured: a resource card (CPU request/limit, memory
request/limit, storage) on the Resources step, stored in
optionalServiceResources.database.

- entity/DTO: add `database` to optionalServiceResources
- k8s: resolveDatabaseResources() applies user-selected resources on both
  the Helm and K8s-API deploy paths (was hardcoded 100m/256Mi→500m/512Mi)
- billing: bill database CPU/RAM as a separate line on top of the app's
  resources; merge it through the upgrade path too
- wizard: db resource card on the Resources step, disk moved into the card,
  cost preview + review summary include the database resources

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-16 01:20:33 +03:30
parent e53fc8e2ff
commit 901a20eb01
11 changed files with 198 additions and 74 deletions
@@ -18,6 +18,12 @@ import { AppRuntime, DatabaseType, ProductType } from '../../common/enums';
import { OptionalServiceResourcesDto } from '../../billing/dto/optional-service-resources.dto';
export class OptionalServiceResourcesMapDto {
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
database?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@@ -82,9 +82,16 @@ export class Application {
@Column({ type: 'jsonb', nullable: true })
logPaths: string[]; // Custom log paths to collect (e.g., ['/app/logs/*.log'])
/** User-selected CPU/RAM/storage per optional service (redis, rabbitmq). */
/** User-selected CPU/RAM/storage per optional service (database, redis, rabbitmq). */
@Column({ type: 'jsonb', nullable: true })
optionalServiceResources?: {
database?: {
cpuRequest?: string;
cpuLimit: string;
memoryRequest?: string;
memoryLimit: string;
storageGi: number;
};
redis?: {
cpuRequest?: string;
cpuLimit: string;
+15 -1
View File
@@ -637,6 +637,16 @@ export class BillingService {
replicas: newResources.replicas ?? base.replicas,
...(newResources.dbStorageSize && { dbStorageSize: newResources.dbStorageSize }),
...(newResources.appStorageSize && { appStorageSize: newResources.appStorageSize }),
...(newResources.databaseResources && {
databaseResources: {
...base.databaseResources,
...newResources.databaseResources,
storageGi:
newResources.databaseResources.storageGi ??
base.databaseResources?.storageGi ??
1,
},
}),
...(newResources.redisResources && {
redisResources: {
...base.redisResources,
@@ -734,7 +744,9 @@ export class BillingService {
? (app as Application).optionalServiceResources
: undefined;
const dtoExtras =
'redisResources' in app ? (app as CalculateCostDto) : undefined;
'redisResources' in app || 'databaseResources' in app
? (app as CalculateCostDto)
: undefined;
const productType =
'productType' in app
? ((app as Application).productType ?? ProductType.APPLICATION)
@@ -753,6 +765,7 @@ export class BillingService {
enableRabbitmq: !!app.enableRabbitmq,
enableElasticsearch: managed ? false : !!app.enableElasticsearch,
enableCustomDomain: managed ? false : enableCustomDomain,
databaseResources: optionalRes?.database ?? dtoExtras?.databaseResources,
redisResources: optionalRes?.redis ?? dtoExtras?.redisResources,
rabbitmqResources: optionalRes?.rabbitmq ?? dtoExtras?.rabbitmqResources,
};
@@ -919,6 +932,7 @@ export class BillingService {
enableRabbitmq: config.enableRabbitmq,
enableElasticsearch: config.enableElasticsearch,
enableCustomDomain: config.enableCustomDomain,
databaseResources: config.databaseResources,
redisResources: config.redisResources,
rabbitmqResources: config.rabbitmqResources,
};
+12
View File
@@ -144,6 +144,12 @@ export class CalculateCostDto {
@IsBoolean()
enableElasticsearch?: boolean;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Per-database CPU/RAM/storage limits' })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
databaseResources?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto })
@IsOptional()
@ValidateNested()
@@ -213,6 +219,12 @@ export class UpgradeResourcesDto {
@IsString()
appStorageSize?: string;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Database resource limits' })
@IsOptional()
@ValidateNested()
@Type(() => OptionalServiceResourcesDto)
databaseResources?: OptionalServiceResourcesDto;
@ApiPropertyOptional({ type: OptionalServiceResourcesDto, description: 'Managed Redis resource limits' })
@IsOptional()
@ValidateNested()
@@ -654,8 +654,14 @@ export class PricingCatalogService implements OnModuleInit {
}
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;
let cpuQty = this.parseCpuToCores(dto.cpuLimit) * replicas;
let memoryQty = this.parseMemoryToGb(dto.memoryLimit) * replicas;
// The database runs as its own single-replica workload; bill its CPU/RAM on
// top of the app's, using the user-selected per-database resources.
if (hasDatabase && dto.databaseResources) {
cpuQty += this.parseCpuToCores(dto.databaseResources.cpuLimit);
memoryQty += this.parseMemoryToGb(dto.databaseResources.memoryLimit);
}
const storageQty =
(dto.dbStorageSize
? parseFloat(String(dto.dbStorageSize).replace(/Gi$/i, '')) || 0
+35 -14
View File
@@ -38,6 +38,10 @@ interface ManifestContext {
dbPassword: string;
dbVersion: string;
dbStorageSize: string;
dbCpuRequest: string;
dbCpuLimit: string;
dbMemoryRequest: string;
dbMemoryLimit: string;
appStorageSize: string;
enableRedis: boolean;
redisVersion: string;
@@ -126,6 +130,21 @@ export class KubernetesService implements OnModuleInit {
/**
* Build Helm values object from an Application entity and image URI.
*/
/**
* Resolve the database workload's CPU/RAM. Prefers the user-selected
* per-database resources (optionalServiceResources.database); falls back to
* the app's main resources for managed databases / legacy apps.
*/
private resolveDatabaseResources(app: Application) {
const res = app.optionalServiceResources?.database;
return {
cpuRequest: res?.cpuRequest || app.cpuRequest || '100m',
cpuLimit: res?.cpuLimit || app.cpuLimit || '500m',
memoryRequest: res?.memoryRequest || app.memoryRequest || '256Mi',
memoryLimit: res?.memoryLimit || app.memoryLimit || '512Mi',
};
}
private buildRedisHelmBlock(app: Application) {
const res = app.optionalServiceResources?.redis;
const storageGi = res?.storageGi ?? 1;
@@ -254,12 +273,7 @@ export class KubernetesService implements OnModuleInit {
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',
},
resources: this.resolveDatabaseResources(app),
},
redis: { enabled: false, storageSize: '1Gi', resources: {} },
rabbitmq: { enabled: false, storageSize: '2Gi', resources: {} },
@@ -342,12 +356,7 @@ export class KubernetesService implements OnModuleInit {
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',
},
resources: this.resolveDatabaseResources(app),
},
wordpress: {
enabled: isWordPress,
@@ -481,6 +490,10 @@ export class KubernetesService implements OnModuleInit {
dbPassword: app.dbPassword || '',
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit,
dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest,
dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit,
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: app.enableRedis || false,
redisVersion: app.redisVersion || '7.2',
@@ -559,6 +572,10 @@ export class KubernetesService implements OnModuleInit {
dbPassword: app.dbPassword || this.generatePassword(),
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit,
dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest,
dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit,
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: false,
redisVersion: app.redisVersion || '7.2',
@@ -626,6 +643,10 @@ export class KubernetesService implements OnModuleInit {
dbPassword: app.dbPassword || this.generatePassword(),
dbVersion: app.dbVersion || '',
dbStorageSize: app.dbStorageSize || '1Gi',
dbCpuRequest: this.resolveDatabaseResources(app).cpuRequest,
dbCpuLimit: this.resolveDatabaseResources(app).cpuLimit,
dbMemoryRequest: this.resolveDatabaseResources(app).memoryRequest,
dbMemoryLimit: this.resolveDatabaseResources(app).memoryLimit,
appStorageSize: app.appStorageSize || '2Gi',
enableRedis: app.enableRedis || false,
redisVersion: app.redisVersion || '7.2',
@@ -1808,8 +1829,8 @@ export class KubernetesService implements OnModuleInit {
env: envVars,
volumeMounts: [{ name: 'db-storage', mountPath: dataPath }],
resources: {
requests: { cpu: '100m', memory: '256Mi' },
limits: { cpu: '500m', memory: '512Mi' },
requests: { cpu: ctx.dbCpuRequest, memory: ctx.dbMemoryRequest },
limits: { cpu: ctx.dbCpuLimit, memory: ctx.dbMemoryLimit },
},
readinessProbe,
livenessProbe,