feat: database management — custom credentials, dump upload/restore

Backend:
- Add dbUsername/dbPassword columns to Application entity
- Add optional DB credential fields to CreateApplicationDto
- Auto-generate dbPassword (crypto.randomBytes) and default dbUsername='appuser'
  when databaseType != 'none' on app creation
- Store both username and password in K8s DB secret (was password-only)
- Read DB_USER/POSTGRES_USER/MYSQL_USER from secretKeyRef instead of hardcoded
- New restoreDatabaseDump() in KubernetesService: creates K8s Job with
  psql/mysql client to restore uploaded SQL dump, waits for completion,
  returns logs
- New POST /applications/:id/db-upload endpoint with 500MB file limit

Frontend:
- Add dbUsername/dbPassword to Application and CreateApplicationDto types
- Deploy page: show username/password fields when database is selected,
  with generate-random-password button and show/hide toggle
- App detail page: new Database section with connection info (host, port,
  db name, username, password with copy-to-clipboard), SQL dump upload
  area with drag-and-drop, and restore output logs display

Security:
- Database remains ClusterIP only (no external exposure)
- Credentials stored in K8s Secrets (base64-encoded)
- Dump file uploaded as temporary K8s Secret, auto-cleaned after restore
This commit is contained in:
keyhan
2026-04-06 22:47:41 +03:30
parent 3c3e0e48fa
commit 9e3347cb71
9 changed files with 496 additions and 13 deletions
@@ -14,6 +14,7 @@ import {
Logger,
Inject,
forwardRef,
BadRequestException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { FileInterceptor } from '@nestjs/platform-express';
@@ -22,7 +23,7 @@ import { ApplicationsService } from './applications.service';
import { CreateApplicationDto, UpdateApplicationDto, ScaleResourcesDto } from './dto/application.dto';
import { RolesGuard } from '../common/guards/roles.guard';
import { Roles } from '../common/decorators/roles.decorator';
import { UserRole } from '../common/enums';
import { UserRole, DatabaseType } from '../common/enums';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { DeploymentsService } from '../deployments/deployments.service';
@@ -60,6 +61,38 @@ export class ApplicationsController {
return this.applicationsService.uploadCode(id, req.user.id, file);
}
@Post(':id/db-upload')
@ApiOperation({ summary: 'Upload and restore a SQL dump into the application database' })
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 500 * 1024 * 1024 }, // 500MB for DB dumps
}))
async uploadDbDump(
@Param('id') id: string,
@Request() req: any,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
const isStaff = req.user.role === UserRole.ADMIN || req.user.role === UserRole.TECHNICAL;
const app = await this.applicationsService.findOne(id, isStaff ? undefined : req.user.id);
if (app.databaseType === DatabaseType.NONE) {
throw new BadRequestException('This application does not have a database configured');
}
this.logger.log(`DB dump upload for ${app.name}${(file.size / 1024).toFixed(1)} KB`);
const result = await this.kubernetesService.restoreDatabaseDump(app, file.buffer);
return {
success: result.success,
message: result.success ? 'Database restored successfully' : 'Database restore failed',
logs: result.logs,
};
}
@Get()
@ApiOperation({ summary: 'List my applications' })
async findAll(@Request() req: any) {
@@ -4,10 +4,11 @@ import { Repository } from 'typeorm';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as path from 'path';
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 } from '../common/enums';
import { UserRole, DatabaseType } from '../common/enums';
@Injectable()
export class ApplicationsService {
@@ -66,11 +67,22 @@ export class ApplicationsService {
this.logger.log(`Manual cluster assignment for app "${dto.name}" → cluster ${clusterId}`);
}
// Generate database credentials if a database is requested
let dbUsername: string | undefined;
let dbPassword: string | undefined;
if (dto.databaseType && dto.databaseType !== DatabaseType.NONE) {
dbUsername = dto.dbUsername?.trim() || 'appuser';
dbPassword = dto.dbPassword?.trim() || crypto.randomBytes(16).toString('hex');
this.logger.log(`Generated DB credentials for app "${dto.name}" — user: ${dbUsername}`);
}
const app = this.appsRepository.create({
...dto,
userId,
clusterId,
poolId,
dbUsername,
dbPassword,
subdomain: `${dto.name}-${userId.split('-')[0]}`,
});
return this.appsRepository.save(app);
@@ -32,6 +32,16 @@ export class CreateApplicationDto {
@IsEnum(DatabaseType)
databaseType: DatabaseType;
@ApiPropertyOptional({ example: 'appuser', description: 'Database username (default: appuser)' })
@IsOptional()
@IsString()
dbUsername?: string;
@ApiPropertyOptional({ example: 'mySecurePass123', description: 'Database password (auto-generated if empty)' })
@IsOptional()
@IsString()
dbPassword?: string;
@ApiPropertyOptional({ example: 'https://github.com/user/repo.git' })
@IsOptional()
@IsString()
@@ -29,6 +29,12 @@ export class Application {
@Column({ type: 'enum', enum: DatabaseType, default: DatabaseType.NONE })
databaseType: DatabaseType;
@Column({ nullable: true })
dbUsername: string;
@Column({ nullable: true })
dbPassword: string;
@Column({ nullable: true })
gitUrl: string;