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:
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user