Phase4
This commit is contained in:
@@ -19,6 +19,7 @@
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
"@nestjs/common": "^11.0.7",
|
||||
"@nestjs/core": "^11.0.7",
|
||||
"@nestjs/platform-fastify": "^11.0.7",
|
||||
|
||||
@@ -320,7 +320,14 @@ export class NodesService {
|
||||
normalized === 'files.list' ||
|
||||
normalized === 'files.read' ||
|
||||
normalized === 'files.write' ||
|
||||
normalized === 'command'
|
||||
normalized === 'command' ||
|
||||
normalized === 'backup.prepare' ||
|
||||
normalized === 'backup.release' ||
|
||||
normalized === 'world.validate' ||
|
||||
normalized === 'world.archive' ||
|
||||
normalized === 'world.replace' ||
|
||||
normalized === 'storage.upload' ||
|
||||
normalized === 'storage.download'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
102
apps/api/src/servers/backups/backups.controller.ts
Normal file
102
apps/api/src/servers/backups/backups.controller.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import {
|
||||
createBackupRequestSchema,
|
||||
restoreBackupRequestSchema,
|
||||
updateBackupScheduleSchema,
|
||||
} from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { BackupsService } from './backups.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/backups')
|
||||
@UseGuards(SessionGuard)
|
||||
export class BackupsController {
|
||||
constructor(private readonly backupsService: BackupsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List server backups' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.backupsService.listBackups(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a manual backup' })
|
||||
create(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = createBackupRequestSchema.parse(body ?? {});
|
||||
return this.backupsService.createBackup(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Get('schedule')
|
||||
@ApiOperation({ summary: 'Get backup schedule' })
|
||||
getSchedule(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.backupsService.getSchedule(user.id, serverId);
|
||||
}
|
||||
|
||||
@Patch('schedule')
|
||||
@ApiOperation({ summary: 'Update backup schedule' })
|
||||
updateSchedule(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = updateBackupScheduleSchema.parse(body);
|
||||
return this.backupsService.updateSchedule(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Get(':backupId/download-url')
|
||||
@ApiOperation({ summary: 'Get a presigned download URL for a backup' })
|
||||
downloadUrl(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('backupId', ParseUUIDPipe) backupId: string,
|
||||
) {
|
||||
return this.backupsService.getDownloadUrl(user.id, serverId, backupId);
|
||||
}
|
||||
|
||||
@Post(':backupId/restore')
|
||||
@ApiOperation({ summary: 'Restore a backup' })
|
||||
restore(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('backupId', ParseUUIDPipe) backupId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
restoreBackupRequestSchema.parse(body);
|
||||
return this.backupsService.restoreBackup(user.id, serverId, backupId);
|
||||
}
|
||||
|
||||
@Delete(':backupId')
|
||||
@ApiOperation({ summary: 'Delete a backup' })
|
||||
deleteBackup(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('backupId', ParseUUIDPipe) backupId: string,
|
||||
) {
|
||||
return this.backupsService.deleteBackup(user.id, serverId, backupId);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/servers/backups/backups.module.ts
Normal file
34
apps/api/src/servers/backups/backups.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
|
||||
import { BackupsController } from './backups.controller';
|
||||
import { BackupsService, SERVER_BACKUPS_QUEUE } from './backups.service';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [BackupsController],
|
||||
providers: [
|
||||
BackupsService,
|
||||
ServerStateService,
|
||||
{
|
||||
provide: SERVER_BACKUPS_QUEUE,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Queue('server-backups', {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [BackupsService, SERVER_BACKUPS_QUEUE],
|
||||
})
|
||||
export class BackupsModule {}
|
||||
341
apps/api/src/servers/backups/backups.service.ts
Normal file
341
apps/api/src/servers/backups/backups.service.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import type { ServerBackup } from '@hexahost/database';
|
||||
import type {
|
||||
BackupActionResponse,
|
||||
BackupDownloadUrlResponse,
|
||||
BackupListResponse,
|
||||
BackupResponse,
|
||||
BackupRestoreResponse,
|
||||
BackupSchedule,
|
||||
CreateBackupRequest,
|
||||
UpdateBackupSchedule,
|
||||
} from '@hexahost/contracts';
|
||||
import {
|
||||
createPresignedGetUrl,
|
||||
createS3Client,
|
||||
deleteStoredObject,
|
||||
loadStorageConfig,
|
||||
} from '@hexahost/storage';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
export const SERVER_BACKUPS_QUEUE = Symbol('SERVER_BACKUPS_QUEUE');
|
||||
|
||||
@Injectable()
|
||||
export class BackupsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(SERVER_BACKUPS_QUEUE) private readonly backupsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async listBackups(userId: string, serverId: string): Promise<BackupListResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const backups = await this.prisma.serverBackup.findMany({
|
||||
where: {
|
||||
serverId,
|
||||
status: { not: 'DELETED' },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return { backups: backups.map((backup) => this.toResponse(backup)) };
|
||||
}
|
||||
|
||||
async createBackup(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: CreateBackupRequest,
|
||||
): Promise<BackupActionResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
if (server.status !== 'RUNNING' && server.status !== 'STOPPED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot create backup while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const previousStatus = server.status;
|
||||
const backup = await this.prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId,
|
||||
type: 'MANUAL',
|
||||
status: 'PENDING',
|
||||
label: input.label ?? 'Manual backup',
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { status: 'BACKING_UP' },
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: previousStatus,
|
||||
toStatus: 'BACKING_UP',
|
||||
reason: 'Manual backup requested',
|
||||
actorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.backupsQueue.add('create-backup', {
|
||||
backupId: backup.id,
|
||||
serverId,
|
||||
previousStatus,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.backup.create',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { backupId: backup.id },
|
||||
});
|
||||
|
||||
return {
|
||||
backup: this.toResponse(backup),
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getDownloadUrl(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupDownloadUrlResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const backup = await this.prisma.serverBackup.findFirstOrThrow({
|
||||
where: { id: backupId, serverId, status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
if (!backup.s3Key) {
|
||||
throw new BadRequestException('Backup is not stored yet');
|
||||
}
|
||||
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const expiresAt = new Date(Date.now() + 15 * 60 * 1000);
|
||||
const downloadUrl = await createPresignedGetUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
backup.s3Key,
|
||||
15 * 60,
|
||||
);
|
||||
|
||||
return {
|
||||
downloadUrl,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async deleteBackup(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const backup = await this.prisma.serverBackup.findFirstOrThrow({
|
||||
where: { id: backupId, serverId },
|
||||
});
|
||||
|
||||
if (backup.s3Key) {
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
await deleteStoredObject(client, config.S3_BUCKET, backup.s3Key);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.serverBackup.update({
|
||||
where: { id: backupId },
|
||||
data: { status: 'DELETED' },
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.backup.delete',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { backupId },
|
||||
});
|
||||
|
||||
return this.toResponse(updated);
|
||||
}
|
||||
|
||||
async restoreBackup(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
backupId: string,
|
||||
): Promise<BackupRestoreResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
|
||||
if (server.status === 'RUNNING') {
|
||||
throw new BadRequestException('Server must be stopped before restore');
|
||||
}
|
||||
|
||||
if (server.status !== 'STOPPED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot restore while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const backup = await this.prisma.serverBackup.findFirstOrThrow({
|
||||
where: { id: backupId, serverId, status: 'AVAILABLE' },
|
||||
});
|
||||
|
||||
const restore = await this.prisma.backupRestore.create({
|
||||
data: {
|
||||
serverId,
|
||||
backupId: backup.id,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { status: 'RESTORING' },
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: 'STOPPED',
|
||||
toStatus: 'RESTORING',
|
||||
reason: 'Restore requested',
|
||||
actorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.backupsQueue.add('restore-backup', {
|
||||
restoreId: restore.id,
|
||||
serverId,
|
||||
backupId: backup.id,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.backup.restore',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { backupId, restoreId: restore.id },
|
||||
});
|
||||
|
||||
return {
|
||||
restore: {
|
||||
id: restore.id,
|
||||
serverId,
|
||||
backupId: backup.id,
|
||||
safetyBackupId: restore.safetyBackupId,
|
||||
status: restore.status,
|
||||
failureReason: restore.failureReason,
|
||||
createdAt: restore.createdAt.toISOString(),
|
||||
completedAt: restore.completedAt?.toISOString() ?? null,
|
||||
},
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async getSchedule(userId: string, serverId: string): Promise<BackupSchedule> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const schedule = await this.prisma.backupSchedule.upsert({
|
||||
where: { serverId },
|
||||
create: {
|
||||
serverId,
|
||||
enabled: false,
|
||||
intervalHours: 24,
|
||||
retentionCount: 5,
|
||||
nextRunAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
update: {},
|
||||
});
|
||||
|
||||
return this.toScheduleResponse(schedule);
|
||||
}
|
||||
|
||||
async updateSchedule(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: UpdateBackupSchedule,
|
||||
): Promise<BackupSchedule> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const schedule = await this.prisma.backupSchedule.upsert({
|
||||
where: { serverId },
|
||||
create: {
|
||||
serverId,
|
||||
enabled: input.enabled ?? false,
|
||||
intervalHours: input.intervalHours ?? 24,
|
||||
retentionCount: input.retentionCount ?? 5,
|
||||
nextRunAt: new Date(Date.now() + (input.intervalHours ?? 24) * 60 * 60 * 1000),
|
||||
},
|
||||
update: {
|
||||
enabled: input.enabled,
|
||||
intervalHours: input.intervalHours,
|
||||
retentionCount: input.retentionCount,
|
||||
nextRunAt:
|
||||
input.enabled === true
|
||||
? new Date(Date.now() + (input.intervalHours ?? 24) * 60 * 60 * 1000)
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
return this.toScheduleResponse(schedule);
|
||||
}
|
||||
|
||||
private toResponse(backup: ServerBackup): BackupResponse {
|
||||
return {
|
||||
id: backup.id,
|
||||
serverId: backup.serverId,
|
||||
type: backup.type,
|
||||
status: backup.status,
|
||||
s3Key: backup.s3Key,
|
||||
sizeBytes: backup.sizeBytes?.toString() ?? null,
|
||||
sha256: backup.sha256,
|
||||
label: backup.label,
|
||||
failureReason: backup.failureReason,
|
||||
createdAt: backup.createdAt.toISOString(),
|
||||
completedAt: backup.completedAt?.toISOString() ?? null,
|
||||
expiresAt: backup.expiresAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private toScheduleResponse(schedule: {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
retentionCount: number;
|
||||
nextRunAt: Date | null;
|
||||
lastRunAt: Date | null;
|
||||
}): BackupSchedule {
|
||||
return {
|
||||
enabled: schedule.enabled,
|
||||
intervalHours: schedule.intervalHours,
|
||||
retentionCount: schedule.retentionCount,
|
||||
nextRunAt: schedule.nextRunAt?.toISOString() ?? null,
|
||||
lastRunAt: schedule.lastRunAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,12 @@ const ALLOWED_TRANSITIONS: Record<GameServerStatus, GameServerStatus[]> = {
|
||||
DRAFT: ['PROVISIONING', 'DELETING'],
|
||||
PROVISIONING: ['INSTALLING', 'ERROR', 'DELETING'],
|
||||
INSTALLING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||
STOPPED: ['STARTING', 'PROVISIONING', 'DELETING'],
|
||||
STOPPED: ['STARTING', 'PROVISIONING', 'BACKING_UP', 'RESTORING', 'DELETING'],
|
||||
STARTING: ['RUNNING', 'STOPPED', 'ERROR', 'DELETING'],
|
||||
RUNNING: ['STOPPING', 'ERROR', 'DELETING'],
|
||||
RUNNING: ['STOPPING', 'BACKING_UP', 'ERROR', 'DELETING'],
|
||||
STOPPING: ['STOPPED', 'ERROR', 'DELETING'],
|
||||
BACKING_UP: ['RUNNING', 'STOPPED', 'ERROR'],
|
||||
RESTORING: ['STOPPED', 'ERROR'],
|
||||
ERROR: ['STOPPED', 'STARTING', 'PROVISIONING', 'DELETING'],
|
||||
DELETING: ['DELETED'],
|
||||
DELETED: [],
|
||||
@@ -21,6 +23,8 @@ const IN_PROGRESS_STATUSES: GameServerStatus[] = [
|
||||
'INSTALLING',
|
||||
'STARTING',
|
||||
'STOPPING',
|
||||
'BACKING_UP',
|
||||
'RESTORING',
|
||||
'DELETING',
|
||||
];
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { WorldsModule } from './worlds/worlds.module';
|
||||
import { PlayersModule } from './players/players.module';
|
||||
import { PropertiesModule } from './properties/properties.module';
|
||||
import { ServerStateService } from './server-state.service';
|
||||
@@ -28,6 +30,8 @@ import { PlansService } from './plans.service';
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => BackupsModule),
|
||||
forwardRef(() => WorldsModule),
|
||||
forwardRef(() => FilesModule),
|
||||
forwardRef(() => PropertiesModule),
|
||||
forwardRef(() => PlayersModule),
|
||||
|
||||
@@ -314,6 +314,7 @@ export class ServersService {
|
||||
hostPort: server.hostPort,
|
||||
containerId: server.containerId,
|
||||
dataPath: server.dataPath,
|
||||
activeWorldName: server.activeWorldName,
|
||||
ramMb: server.ramMb,
|
||||
createdAt: server.createdAt.toISOString(),
|
||||
updatedAt: server.updatedAt.toISOString(),
|
||||
|
||||
63
apps/api/src/servers/worlds/worlds.controller.ts
Normal file
63
apps/api/src/servers/worlds/worlds.controller.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { initiateWorldUploadSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { WorldsService } from './worlds.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/worlds')
|
||||
@UseGuards(SessionGuard)
|
||||
export class WorldsController {
|
||||
constructor(private readonly worldsService: WorldsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Get world overview' })
|
||||
info(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.worldsService.getWorldInfo(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post('uploads')
|
||||
@ApiOperation({ summary: 'Initiate a world upload' })
|
||||
initiateUpload(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = initiateWorldUploadSchema.parse(body);
|
||||
return this.worldsService.initiateUpload(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Post('uploads/:uploadId/complete')
|
||||
@ApiOperation({ summary: 'Complete a world upload and apply it' })
|
||||
completeUpload(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('uploadId', ParseUUIDPipe) uploadId: string,
|
||||
) {
|
||||
return this.worldsService.completeUpload(user.id, serverId, uploadId);
|
||||
}
|
||||
|
||||
@Post('download')
|
||||
@ApiOperation({ summary: 'Create a backup for world download' })
|
||||
download(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
) {
|
||||
return this.worldsService.requestWorldDownload(user.id, serverId);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/servers/worlds/worlds.module.ts
Normal file
17
apps/api/src/servers/worlds/worlds.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { NodeBridgeModule } from '../../nodes/node-bridge.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { BackupsModule } from '../backups/backups.module';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
import { WorldsController } from './worlds.controller';
|
||||
import { WorldsService } from './worlds.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule, NodeBridgeModule, BackupsModule],
|
||||
controllers: [WorldsController],
|
||||
providers: [WorldsService, ServerStateService],
|
||||
})
|
||||
export class WorldsModule {}
|
||||
250
apps/api/src/servers/worlds/worlds.service.ts
Normal file
250
apps/api/src/servers/worlds/worlds.service.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Inject,
|
||||
Injectable,
|
||||
ServiceUnavailableException,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
InitiateWorldUpload,
|
||||
WorldDownloadUrlResponse,
|
||||
WorldInfoResponse,
|
||||
WorldUploadInitResponse,
|
||||
WorldUploadResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import type { GameServer, WorldUpload } from '@hexahost/database';
|
||||
import {
|
||||
createPresignedPutUrl,
|
||||
createS3Client,
|
||||
headStoredObject,
|
||||
loadStorageConfig,
|
||||
worldUploadObjectKey,
|
||||
} from '@hexahost/storage';
|
||||
|
||||
import { AuditService } from '../../auth/audit.service';
|
||||
import { NodeBridgeService } from '../../nodes/node-bridge.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { findOwnedServer } from '../shared/server-ownership.util';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
import { SERVER_BACKUPS_QUEUE } from '../backups/backups.service';
|
||||
|
||||
const WORLD_MANAGE_STATUSES = new Set<GameServer['status']>(['STOPPED']);
|
||||
|
||||
@Injectable()
|
||||
export class WorldsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nodeBridge: NodeBridgeService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(SERVER_BACKUPS_QUEUE) private readonly backupsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async getWorldInfo(userId: string, serverId: string): Promise<WorldInfoResponse> {
|
||||
const server = await this.requireNode(serverId, userId);
|
||||
|
||||
const response = await this.nodeBridge.sendAgentRequest(
|
||||
server.nodeId!,
|
||||
'server.world.validate',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
worldName: server.activeWorldName,
|
||||
},
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new ServiceUnavailableException(
|
||||
response.errorMessage ?? 'Failed to load world info',
|
||||
);
|
||||
}
|
||||
|
||||
const result = response.result as {
|
||||
worlds?: Array<{
|
||||
name: string;
|
||||
path: string;
|
||||
sizeBytes: number;
|
||||
hasLevelDat: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
return {
|
||||
activeWorldName: server.activeWorldName,
|
||||
worlds: (result.worlds ?? []).map((world) => ({
|
||||
name: world.name,
|
||||
path: world.path,
|
||||
sizeBytes: String(world.sizeBytes),
|
||||
hasLevelDat: world.hasLevelDat,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async initiateUpload(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: InitiateWorldUpload,
|
||||
): Promise<WorldUploadInitResponse> {
|
||||
const server = await this.requireStoppedServer(userId, serverId);
|
||||
const uploadId = randomUUID();
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const s3Key = worldUploadObjectKey(serverId, uploadId);
|
||||
const expiresAt = new Date(Date.now() + 60 * 60 * 1000);
|
||||
const uploadUrl = await createPresignedPutUrl(
|
||||
client,
|
||||
config.S3_BUCKET,
|
||||
s3Key,
|
||||
60 * 60,
|
||||
'application/gzip',
|
||||
);
|
||||
|
||||
await this.prisma.worldUpload.create({
|
||||
data: {
|
||||
id: uploadId,
|
||||
serverId,
|
||||
status: 'UPLOADING',
|
||||
s3Key,
|
||||
worldName: input.worldName,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
uploadId,
|
||||
uploadUrl,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
s3Key,
|
||||
};
|
||||
}
|
||||
|
||||
async completeUpload(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
uploadId: string,
|
||||
): Promise<WorldUploadResponse> {
|
||||
const server = await this.requireStoppedServer(userId, serverId);
|
||||
const upload = await this.prisma.worldUpload.findFirstOrThrow({
|
||||
where: { id: uploadId, serverId },
|
||||
});
|
||||
|
||||
const config = loadStorageConfig();
|
||||
const client = createS3Client(config);
|
||||
const head = await headStoredObject(client, config.S3_BUCKET, upload.s3Key);
|
||||
|
||||
if (head.sizeBytes <= 0) {
|
||||
throw new BadRequestException('Uploaded world archive is empty');
|
||||
}
|
||||
|
||||
const updated = await this.prisma.worldUpload.update({
|
||||
where: { id: uploadId },
|
||||
data: {
|
||||
status: 'READY',
|
||||
sizeBytes: BigInt(head.sizeBytes),
|
||||
},
|
||||
});
|
||||
|
||||
await this.backupsQueue.add('apply-world-upload', {
|
||||
uploadId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.world.upload',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { uploadId, worldName: upload.worldName },
|
||||
});
|
||||
|
||||
return this.toUploadResponse(updated);
|
||||
}
|
||||
|
||||
async requestWorldDownload(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<WorldDownloadUrlResponse> {
|
||||
const server = await this.requireNode(serverId, userId);
|
||||
|
||||
if (server.status !== 'RUNNING' && server.status !== 'STOPPED') {
|
||||
throw new BadRequestException(
|
||||
`Cannot download world while server is ${server.status}`,
|
||||
);
|
||||
}
|
||||
|
||||
const backup = await this.prisma.serverBackup.create({
|
||||
data: {
|
||||
serverId,
|
||||
type: 'MANUAL',
|
||||
status: 'PENDING',
|
||||
label: 'World download',
|
||||
},
|
||||
});
|
||||
|
||||
const previousStatus = server.status as 'RUNNING' | 'STOPPED';
|
||||
|
||||
await this.prisma.gameServer.update({
|
||||
where: { id: serverId },
|
||||
data: { status: 'BACKING_UP' },
|
||||
});
|
||||
|
||||
await this.prisma.gameServerStateTransition.create({
|
||||
data: {
|
||||
gameServerId: serverId,
|
||||
fromStatus: server.status,
|
||||
toStatus: 'BACKING_UP',
|
||||
reason: 'World download backup started',
|
||||
actorId: userId,
|
||||
},
|
||||
});
|
||||
|
||||
await this.backupsQueue.add('create-backup', {
|
||||
backupId: backup.id,
|
||||
serverId,
|
||||
previousStatus,
|
||||
});
|
||||
|
||||
return {
|
||||
backupId: backup.id,
|
||||
downloadUrl: '',
|
||||
expiresAt: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async requireNode(serverId: string, userId: string): Promise<GameServer> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
if (!server.nodeId) {
|
||||
throw new BadRequestException('Server is not assigned to a node');
|
||||
}
|
||||
return server;
|
||||
}
|
||||
|
||||
private async requireStoppedServer(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
): Promise<GameServer> {
|
||||
const server = await this.requireNode(serverId, userId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!WORLD_MANAGE_STATUSES.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
`World changes require server to be STOPPED (current: ${server.status})`,
|
||||
);
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
private toUploadResponse(upload: WorldUpload): WorldUploadResponse {
|
||||
return {
|
||||
id: upload.id,
|
||||
serverId: upload.serverId,
|
||||
status: upload.status,
|
||||
worldName: upload.worldName,
|
||||
sizeBytes: upload.sizeBytes?.toString() ?? null,
|
||||
failureReason: upload.failureReason,
|
||||
createdAt: upload.createdAt.toISOString(),
|
||||
completedAt: upload.completedAt?.toISOString() ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user