Files
cloud-host/backend/src/snapshots/snapshots.service.ts
T
keyhan 8b77656bb7 chore(deps): upgrade all dependencies to latest stable
Bring backend and frontend to the latest stable releases (no pre-releases),
including major upgrades that required code migration. Both projects pass
typecheck and production builds.

Backend
- NestJS 10 -> 11 (common/core/platform-express/jwt/passport/bull/cli/
  schematics/testing), @nestjs/config 3->4, @nestjs/swagger 7->11,
  @nestjs/typeorm 10->11
- @kubernetes/client-node 0.21 -> 1.4: migrate ~200+ call sites across 6
  services to the v1 single-object argument API, unwrapped responses, err.code,
  setHeaderOptions for patch content-type, applyToHTTPSOptions. Add regression
  spec k8s-client-v1-migration.spec.ts.
- typeorm 0.3 -> 1.0: relations/select string arrays -> object form
- uuid 9->14 (drops @types/uuid), multer 1->2, bcrypt 5->6, helmet 7->8,
  class-validator 0.14->0.15
- TypeScript 5->6, ESLint 8->9, @typescript-eslint 6->8, jest 29->30,
  @types/node 20->24; tsconfig: strictPropertyInitialization:false,
  ignoreDeprecations, rootDir, explicit types[]
- @nestjs/config 4: jwt.strategy uses getOrThrow; @types/express kept at 4
  (Nest 11 runs Express 4)

Frontend
- React 18->19, Next 14->16 (async params via official codemod),
  Tailwind 3->4 (@tailwindcss/postcss, @import + @config, inline custom @apply),
  framer-motion 11->12, zustand 4->5, three 0.169->0.184, @react-three/* majors
- TypeScript 5->6 (tsconfig target es5->ES2017), ESLint 8->9,
  eslint-config-next 14->16

Infra/docs
- Dockerfiles node:20-alpine -> node:24-alpine (require-esm for k8s client)
- Add UPGRADE.md / UPGRADE.en.md; refresh README tech-stack versions

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 18:05:33 +03:30

478 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
import * as fs from 'fs';
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, ProductType } from '../common/enums';
const MAX_SNAPSHOTS = 10;
@Injectable()
export class SnapshotsService implements OnModuleInit {
private readonly logger = new Logger(SnapshotsService.name);
constructor(
@InjectRepository(AppSnapshot)
private snapshotsRepo: Repository<AppSnapshot>,
@Inject(forwardRef(() => ApplicationsService))
private applicationsService: ApplicationsService,
private kubernetesService: KubernetesService,
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.
*/
async createSnapshot(
applicationId: string,
userId: string,
type: SnapshotType = SnapshotType.MANUAL,
label?: string,
): Promise<AppSnapshot> {
const app = await this.applicationsService.findOne(applicationId, userId);
const snapshot = this.snapshotsRepo.create({
applicationId: app.id,
createdBy: userId,
type,
status: SnapshotStatus.IN_PROGRESS,
progress: 0,
label: label || `Snapshot ${new Date().toLocaleString()}`,
imageTag: app.latestImageTag || undefined,
hasDatabase: app.databaseType !== DatabaseType.NONE,
} as Partial<AppSnapshot>);
const saved = await this.snapshotsRepo.save(snapshot);
// Run snapshot capture async
this.captureSnapshot(saved.id, app).catch((err) => {
this.logger.error(`Snapshot ${saved.id} capture failed: ${err.message}`);
});
return saved;
}
/**
* Create a pre-deploy snapshot (called automatically before each deploy).
* Does NOT require userId ownership check — called internally.
*/
async createPreDeploySnapshot(applicationId: string, userId: string, version: string): Promise<AppSnapshot> {
// Use findOne without userId to bypass ownership (internal call)
const app = await this.applicationsService.findOne(applicationId);
const snapshot = this.snapshotsRepo.create({
applicationId: app.id,
createdBy: userId,
type: SnapshotType.PRE_DEPLOY,
status: SnapshotStatus.IN_PROGRESS,
label: `Before deploy ${version}`,
imageTag: app.latestImageTag || undefined,
hasDatabase: app.databaseType !== DatabaseType.NONE,
} as Partial<AppSnapshot>);
const saved = await this.snapshotsRepo.save(snapshot);
// Run snapshot capture (await it for pre-deploy to ensure it completes before deploy)
await this.captureSnapshot(saved.id, app);
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 {
await this.setSnapshotProgress(snapshotId, 5);
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}`);
}
}
}
// 3. Dump database
if (app.databaseType !== DatabaseType.NONE) {
await this.setSnapshotProgress(snapshotId, managedDbOnly ? 10 : 25);
try {
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;
updates.dbDumpSize = data.length;
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}`);
}
await this.snapshotsRepo.update(snapshotId, updates);
// Prune old snapshots (keep max 10)
await this.pruneSnapshots(app.id);
}
/**
* Keep only the latest MAX_SNAPSHOTS per application. Delete older ones + their files.
*/
private async pruneSnapshots(applicationId: string): Promise<void> {
const all = await this.snapshotsRepo.find({
where: { applicationId },
order: { createdAt: 'DESC' },
});
if (all.length <= MAX_SNAPSHOTS) return;
const toDelete = all.slice(MAX_SNAPSHOTS);
for (const snap of toDelete) {
this.deleteSnapshotFiles(snap);
await this.snapshotsRepo.remove(snap);
}
this.logger.log(`Pruned ${toDelete.length} old snapshot(s) for app ${applicationId}`);
}
private deleteSnapshotFiles(snap: AppSnapshot): void {
for (const filePath of [snap.appArchivePath, snap.wpContentArchivePath, snap.dbDumpPath]) {
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
} catch {}
}
}
// Try to remove the snapshot directory
if (snap.appArchivePath) {
const dir = path.dirname(snap.appArchivePath);
try {
if (fs.existsSync(dir)) fs.rmdirSync(dir);
} catch {}
}
}
/**
* List snapshots for an application (newest first).
*/
async listSnapshots(applicationId: string, userId: string): Promise<AppSnapshot[]> {
// Verify user has access
await this.applicationsService.findOne(applicationId, userId);
return this.snapshotsRepo.find({
where: { applicationId },
order: { createdAt: 'DESC' },
take: MAX_SNAPSHOTS,
});
}
/**
* Get a single snapshot (with ownership check).
*/
async findOne(snapshotId: string, userId: string): Promise<AppSnapshot> {
const snapshot = await this.snapshotsRepo.findOne({
where: { id: snapshotId },
relations: { application: true },
});
if (!snapshot) throw new NotFoundException('Snapshot not found');
// Verify ownership
await this.applicationsService.findOne(snapshot.applicationId, userId);
return snapshot;
}
/**
* Download a snapshot artifact (source, wp-content, or database).
*/
async getDownloadPath(
snapshotId: string,
userId: string,
artifact: 'source' | 'wp-content' | 'database',
): Promise<{ filePath: string; fileName: string }> {
const snapshot = await this.findOne(snapshotId, userId);
let filePath: string | null = null;
let fileName = '';
switch (artifact) {
case 'source':
filePath = snapshot.appArchivePath;
fileName = `${snapshot.applicationId}-source-${snapshot.id.slice(0, 8)}.zip`;
break;
case 'wp-content':
filePath = snapshot.wpContentArchivePath;
fileName = `${snapshot.applicationId}-wp-content-${snapshot.id.slice(0, 8)}.tar.gz`;
break;
case 'database':
filePath = snapshot.dbDumpPath;
fileName = `${snapshot.applicationId}-database-${snapshot.id.slice(0, 8)}.sql`;
break;
}
if (!filePath || !fs.existsSync(filePath)) {
throw new NotFoundException(`Snapshot artifact "${artifact}" not found or has been deleted`);
}
return { filePath, fileName };
}
/**
* Download the CURRENT live state of the app (not from a snapshot).
* Creates a temporary archive of the current source code.
*/
async downloadCurrentSource(applicationId: string, userId: string): Promise<{ filePath: string; fileName: string } | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.codePath && fs.existsSync(app.codePath)) {
return { filePath: app.codePath, fileName: `${app.name}-current-source.zip` };
}
return null;
}
/**
* Download the current live wp-content from the running WordPress app.
*/
async downloadCurrentWpContent(applicationId: string, userId: string): Promise<Buffer | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.runtime !== AppRuntime.WORDPRESS) {
throw new BadRequestException('Only WordPress applications have wp-content');
}
const { data } = await this.kubernetesService.archiveWpContent(app);
return data;
}
/**
* Download the current live database dump.
*/
async downloadCurrentDatabase(applicationId: string, userId: string): Promise<Buffer | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.databaseType === DatabaseType.NONE) {
throw new BadRequestException('This application does not have a database');
}
const { data } = await this.kubernetesService.exportDatabaseDump(app);
return data;
}
/**
* Rollback an application to a specific snapshot.
* Uses K8s revision rollback for the app deployment (instant, no rebuild).
* File-based restore for DB dump and wp-content.
*/
async rollbackToSnapshot(snapshotId: string, userId: string): Promise<{ success: boolean; details: string[] }> {
const snapshot = await this.findOne(snapshotId, userId);
if (snapshot.status !== SnapshotStatus.COMPLETED) {
throw new BadRequestException('Cannot rollback to an incomplete or failed snapshot');
}
const app = await this.applicationsService.findOne(snapshot.applicationId, userId);
const details: string[] = [];
// 1. Rollback K8s Deployment via revision (instant — no rebuild)
// Find the revision that matches the snapshot's imageTag
if (snapshot.imageTag) {
try {
const { revisions } = await this.kubernetesService.getDeploymentRevisions(app);
const targetRevision = revisions.find((r) => r.image === snapshot.imageTag);
if (targetRevision && !targetRevision.isCurrent) {
const result = await this.kubernetesService.rollbackDeploymentRevision(app, targetRevision.revision);
if (result.success) {
details.push(`✅ App rolled back to K8s revision ${targetRevision.revision} (instant)`);
this.logger.log(`Rollback ${snapshotId}: K8s revision rollback to ${targetRevision.revision}`);
} else {
details.push(`⚠️ K8s revision rollback failed: ${result.message}`);
}
} else if (targetRevision?.isCurrent) {
details.push('️ App is already running the snapshot\'s image — no deployment change needed');
} else {
// Revision not found — fall back to restoring source code and redeploying
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
const destPath = path.join(appDir, 'source.zip');
fs.mkdirSync(appDir, { recursive: true });
fs.copyFileSync(snapshot.appArchivePath, destPath);
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
details.push('✅ Source code restored (K8s revision expired — will need redeploy)');
this.logger.log(`Rollback ${snapshotId}: source code restored (revision not found)`);
} else {
details.push('⚠️ K8s revision not found and no source archive available');
}
}
} catch (e: any) {
this.logger.warn(`Rollback ${snapshotId}: K8s revision rollback error — ${e.message}`);
details.push(`⚠️ K8s rollback failed: ${e.message}`);
}
}
// 2. Restore wp-content (WordPress) — file-based
if (snapshot.wpContentArchivePath && fs.existsSync(snapshot.wpContentArchivePath)) {
const archiveBuffer = fs.readFileSync(snapshot.wpContentArchivePath);
const result = await this.kubernetesService.restoreWpContent(app, archiveBuffer);
if (result.success) {
details.push('✅ wp-content restored');
this.logger.log(`Rollback ${snapshotId}: restored wp-content`);
} else {
details.push(`⚠️ wp-content restore failed: ${result.logs}`);
this.logger.warn(`Rollback ${snapshotId}: wp-content restore failed`);
}
}
// 3. Restore database — file-based (pass file path for PVC-based transfer)
if (snapshot.dbDumpPath && fs.existsSync(snapshot.dbDumpPath)) {
const result = await this.kubernetesService.restoreDatabaseDump(app, snapshot.dbDumpPath);
if (result.success) {
details.push('✅ Database restored');
this.logger.log(`Rollback ${snapshotId}: restored database`);
} else {
details.push(`⚠️ Database restore failed: ${result.logs}`);
this.logger.warn(`Rollback ${snapshotId}: database restore failed`);
}
}
if (details.length === 0) {
details.push('⚠️ No artifacts found in this snapshot to restore');
}
return { success: true, details };
}
/**
* Rollback using K8s revision directly (without a snapshot).
* Fastest rollback — just switches the active ReplicaSet.
*/
async rollbackToRevision(
applicationId: string,
userId: string,
targetRevision: number,
): Promise<{ success: boolean; message: string }> {
const app = await this.applicationsService.findOne(applicationId, userId);
return this.kubernetesService.rollbackDeploymentRevision(app, targetRevision);
}
/**
* Get K8s deployment revisions for an application.
*/
async getRevisions(applicationId: string, userId: string) {
const app = await this.applicationsService.findOne(applicationId, userId);
return this.kubernetesService.getDeploymentRevisions(app);
}
/**
* Delete a specific snapshot.
*/
async deleteSnapshot(snapshotId: string, userId: string): Promise<void> {
const snapshot = await this.findOne(snapshotId, userId);
this.deleteSnapshotFiles(snapshot);
await this.snapshotsRepo.remove(snapshot);
this.logger.log(`Deleted snapshot ${snapshotId}`);
}
/**
* Delete all snapshots for an application (called when app is deleted).
*/
async deleteAllForApplication(applicationId: string): Promise<void> {
const all = await this.snapshotsRepo.find({ where: { applicationId } });
for (const snap of all) {
this.deleteSnapshotFiles(snap);
}
await this.snapshotsRepo.delete({ applicationId });
this.logger.log(`Deleted all snapshots for app ${applicationId}`);
}
}