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
+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}`);
}