8b77656bb7
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>
478 lines
18 KiB
TypeScript
478 lines
18 KiB
TypeScript
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}`);
|
||
}
|
||
}
|