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;