Phase4
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 13s

This commit is contained in:
smueller
2026-06-26 12:32:27 +02:00
parent 9b061c3ee7
commit 4262464cd5
101 changed files with 4024 additions and 74 deletions

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"088dae477bba6b86","duration":6291,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}

BIN
.turbo/cache/088dae477bba6b86.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts":{"size":739,"mtime_nanos":1782469606261313100,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":546,"mtime_nanos":1782469606260191400,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782469606254548800,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782469631918619700,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782469606255544300,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"0b40f6f2704c48c7","duration":6647,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}

BIN
.turbo/cache/0b40f6f2704c48c7.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/storage/.turbo/turbo-test.log":{"size":413,"mtime_nanos":1782469629407300600,"mode":420,"is_dir":false}},"order":["packages/storage/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"3deec67040f3d455","duration":4142,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}

BIN
.turbo/cache/3deec67040f3d455.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"43d7e0e4235fbc22","duration":6609,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"59c384d11e811723b6199e3773daeb5f73d60d0d3f382006384f3b1169976649"}

BIN
.turbo/cache/43d7e0e4235fbc22.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/web/.turbo/turbo-test.log":{"size":116,"mtime_nanos":1782469626542421200,"mode":420,"is_dir":false}},"order":["apps/web/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"618e3985122b3059","duration":1276,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}

BIN
.turbo/cache/618e3985122b3059.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"68fd9b67d57b66b8","duration":10715,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"8df26c57a922c25cc2cd4f87f7c55bb37714c45c5cdbbbc3b54183cb5eef1a93"}

BIN
.turbo/cache/68fd9b67d57b66b8.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/storage/.turbo/turbo-build.log":{"size":107,"mtime_nanos":1782469564967043200,"mode":420,"is_dir":false},"packages/storage/dist/config.d.ts":{"size":922,"mtime_nanos":1782469564825687900,"mode":420,"is_dir":false},"packages/storage/dist/index.js":{"size":1654,"mtime_nanos":1782469564856008100,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.js.map":{"size":518,"mtime_nanos":1782469564862367100,"mode":420,"is_dir":false},"packages/storage/dist/index.d.ts.map":{"size":291,"mtime_nanos":1782469564858346600,"mode":420,"is_dir":false},"packages/storage/dist/client.js":{"size":2298,"mtime_nanos":1782469564839389300,"mode":420,"is_dir":false},"packages/storage/dist/config.d.ts.map":{"size":358,"mtime_nanos":1782469564822341100,"mode":420,"is_dir":false},"packages/storage/dist/index.d.ts":{"size":290,"mtime_nanos":1782469564859351000,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.d.ts.map":{"size":112,"mtime_nanos":1782469564865503900,"mode":420,"is_dir":false},"packages/storage/dist/index.js.map":{"size":339,"mtime_nanos":1782469564854781600,"mode":420,"is_dir":false},"packages/storage/dist/client.d.ts.map":{"size":960,"mtime_nanos":1782469564844386600,"mode":420,"is_dir":false},"packages/storage/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/storage/dist/config.js.map":{"size":967,"mtime_nanos":1782469564792460200,"mode":420,"is_dir":false},"packages/storage/dist/client.js.map":{"size":1685,"mtime_nanos":1782469564837692200,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.js":{"size":623,"mtime_nanos":1782469564863473800,"mode":420,"is_dir":false},"packages/storage/dist/config.js":{"size":1011,"mtime_nanos":1782469564793946800,"mode":420,"is_dir":false},"packages/storage/dist/client.d.ts":{"size":925,"mtime_nanos":1782469564845387400,"mode":420,"is_dir":false},"packages/storage/dist/keys.test.d.ts":{"size":50,"mtime_nanos":1782469564866493700,"mode":420,"is_dir":false}},"order":["packages/storage/.turbo/turbo-build.log","packages/storage/dist","packages/storage/dist/client.d.ts","packages/storage/dist/client.d.ts.map","packages/storage/dist/client.js","packages/storage/dist/client.js.map","packages/storage/dist/config.d.ts","packages/storage/dist/config.d.ts.map","packages/storage/dist/config.js","packages/storage/dist/config.js.map","packages/storage/dist/index.d.ts","packages/storage/dist/index.d.ts.map","packages/storage/dist/index.js","packages/storage/dist/index.js.map","packages/storage/dist/keys.test.d.ts","packages/storage/dist/keys.test.d.ts.map","packages/storage/dist/keys.test.js","packages/storage/dist/keys.test.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"70b32a99fb5043c4","duration":4945,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}

BIN
.turbo/cache/70b32a99fb5043c4.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/worker/.turbo/turbo-test.log":{"size":833,"mtime_nanos":1782469633368857500,"mode":420,"is_dir":false}},"order":["apps/worker/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"715be903aa661ca3","duration":1440,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}

BIN
.turbo/cache/715be903aa661ca3.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/contracts/.turbo/turbo-test.log":{"size":459,"mtime_nanos":1782469629194402000,"mode":420,"is_dir":false}},"order":["packages/contracts/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"8d2682a0fba961c5","duration":3939,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}

BIN
.turbo/cache/8d2682a0fba961c5.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"93fd20f8ea67baeb","duration":2910,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}

BIN
.turbo/cache/93fd20f8ea67baeb.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"ca0b4945c3af0662","duration":4668,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"8df26c57a922c25cc2cd4f87f7c55bb37714c45c5cdbbbc3b54183cb5eef1a93"}

BIN
.turbo/cache/ca0b4945c3af0662.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false},"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782465829239596900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts.map":{"size":493,"mtime_nanos":1782469570117618900,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/.turbo/turbo-build.log":{"size":557,"mtime_nanos":1782469570242487900,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":659,"mtime_nanos":1782465829247304200,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"cb989f8003d05922","duration":10209,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"f8f1a7b2145354c3968ce510dc6a7fca0fdd34bd20b88002d8b0ee435c733afa"}

BIN
.turbo/cache/cb989f8003d05922.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"apps/api/.turbo/turbo-test.log":{"size":2454,"mtime_nanos":1782469642397105100,"mode":420,"is_dir":false}},"order":["apps/api/.turbo/turbo-test.log"]}

View File

@@ -0,0 +1 @@
{"hash":"d1370c34f934edc9","duration":10467,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"da0fce55d08fc61ced7c2f6406de2005aae08a1a6288ad912dc67be107187a0f"}

BIN
.turbo/cache/d1370c34f934edc9.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"e23c3ddbaecd7f7a","duration":1884,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"59c384d11e811723b6199e3773daeb5f73d60d0d3f382006384f3b1169976649"}

BIN
.turbo/cache/e23c3ddbaecd7f7a.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1 @@
{"files":{"packages/database/dist/index.js.map":{"size":617,"mtime_nanos":1782469606254548800,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts.map":{"size":131,"mtime_nanos":1782466100085644700,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.d.ts":{"size":56,"mtime_nanos":1782466100087324100,"mode":420,"is_dir":false},"packages/database/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/.turbo/turbo-build.log":{"size":509,"mtime_nanos":1782469606374958300,"mode":420,"is_dir":false},"packages/database/dist/cli":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/database/dist/index.d.ts.map":{"size":546,"mtime_nanos":1782469606260191400,"mode":420,"is_dir":false},"packages/database/dist/index.js":{"size":962,"mtime_nanos":1782469606255544300,"mode":420,"is_dir":false},"packages/database/dist/index.d.ts":{"size":739,"mtime_nanos":1782469606261313100,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js":{"size":4338,"mtime_nanos":1782466100081295500,"mode":420,"is_dir":false},"packages/database/dist/cli/bootstrap-admin.js.map":{"size":3941,"mtime_nanos":1782466100078293500,"mode":420,"is_dir":false}},"order":["packages/database/.turbo/turbo-build.log","packages/database/dist","packages/database/dist/cli","packages/database/dist/cli/bootstrap-admin.d.ts","packages/database/dist/cli/bootstrap-admin.d.ts.map","packages/database/dist/cli/bootstrap-admin.js","packages/database/dist/cli/bootstrap-admin.js.map","packages/database/dist/index.d.ts","packages/database/dist/index.d.ts.map","packages/database/dist/index.js","packages/database/dist/index.js.map"]}

View File

@@ -0,0 +1 @@
{"hash":"e6b183182d9648a1","duration":7621,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"59c384d11e811723b6199e3773daeb5f73d60d0d3f382006384f3b1169976649"}

BIN
.turbo/cache/e6b183182d9648a1.tar.zst vendored Normal file

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"hash":"fc1229a0da7d5e9a","duration":37281,"sha":"9b061c3ee7838ca0dba06626b1672091d8dbfbf5","dirty_hash":"8df26c57a922c25cc2cd4f87f7c55bb37714c45c5cdbbbc3b54183cb5eef1a93"}

BIN
.turbo/cache/fc1229a0da7d5e9a.tar.zst vendored Normal file

Binary file not shown.

View File

@@ -92,21 +92,21 @@ make build
## Aktueller Stand
**Phase 3** (Konsole, Dateien, Einstellungen) — abgeschlossen.
**Phase 4** (Welten & Backups) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.
### Server-Verwaltung im Panel
Nach dem Start eines Servers stehen unter `/servers/[id]` folgende Bereiche zur Verfügung:
| Tab | Funktion |
|-----|----------|
| Übersicht | Status, Start/Stop/Restart, Verbindungsadresse |
| Konsole | Live-Logs (WebSocket), Befehle per RCON (nur `RUNNING`) |
| Dateien | Verzeichnisliste, Dateien lesen/schreiben |
| Eigenschaften | `server.properties` bearbeiten, optional Neustart |
| Spieler | Online-Spieler und Whitelist (Lesen) |
| Konsole | Live-Logs, RCON-Befehle |
| Dateien | Verzeichnisliste, Dateien bearbeiten |
| Eigenschaften | `server.properties` |
| Spieler | Online-Spieler, Whitelist |
| **Welten** | Welten anzeigen, Upload/Download (Server gestoppt) |
| **Backups** | Manuell/geplant, Restore, Retention |
### Minecraft-Server lokal testen

View File

@@ -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",

View File

@@ -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'
);
}

View 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);
}
}

View 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 {}

View 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,
};
}
}

View File

@@ -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',
];

View File

@@ -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),

View File

@@ -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(),

View 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);
}
}

View 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 {}

View 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

View File

@@ -0,0 +1,159 @@
package archive
import (
"archive/tar"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
// CreateTarGz archives sourceDir into outputPath and returns checksum and size.
func CreateTarGz(sourceDir, outputPath string) (sha256sum string, size int64, err error) {
sourceDir = filepath.Clean(sourceDir)
info, err := os.Stat(sourceDir)
if err != nil {
return "", 0, err
}
if !info.IsDir() {
return "", 0, fmt.Errorf("source is not a directory")
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return "", 0, err
}
file, err := os.Create(outputPath)
if err != nil {
return "", 0, err
}
defer file.Close()
hasher := sha256.New()
writer := io.MultiWriter(file, hasher)
gzipWriter := gzip.NewWriter(writer)
tarWriter := tar.NewWriter(gzipWriter)
err = filepath.Walk(sourceDir, func(path string, entry os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(sourceDir, path)
if err != nil {
return err
}
if rel == "." {
return nil
}
rel = filepath.ToSlash(rel)
header, err := tar.FileInfoHeader(entry, "")
if err != nil {
return err
}
header.Name = rel
if err := tarWriter.WriteHeader(header); err != nil {
return err
}
if entry.IsDir() {
return nil
}
sourceFile, err := os.Open(path)
if err != nil {
return err
}
defer sourceFile.Close()
_, err = io.Copy(tarWriter, sourceFile)
return err
})
if err != nil {
return "", 0, err
}
if err := tarWriter.Close(); err != nil {
return "", 0, err
}
if err := gzipWriter.Close(); err != nil {
return "", 0, err
}
stat, err := file.Stat()
if err != nil {
return "", 0, err
}
return hex.EncodeToString(hasher.Sum(nil)), stat.Size(), nil
}
// ExtractTarGz extracts archivePath into destDir.
func ExtractTarGz(archivePath, destDir string) error {
destDir = filepath.Clean(destDir)
if err := os.MkdirAll(destDir, 0o755); err != nil {
return err
}
file, err := os.Open(archivePath)
if err != nil {
return err
}
defer file.Close()
gzipReader, err := gzip.NewReader(file)
if err != nil {
return err
}
defer gzipReader.Close()
tarReader := tar.NewReader(gzipReader)
for {
header, err := tarReader.Next()
if err == io.EOF {
break
}
if err != nil {
return err
}
target := filepath.Join(destDir, filepath.FromSlash(header.Name))
if !strings.HasPrefix(filepath.Clean(target), destDir+string(os.PathSeparator)) && filepath.Clean(target) != destDir {
return fmt.Errorf("archive entry escapes destination: %s", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.MkdirAll(target, 0o755); err != nil {
return err
}
case tar.TypeReg:
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(header.Mode))
if err != nil {
return err
}
if _, err := io.Copy(out, tarReader); err != nil {
out.Close()
return err
}
out.Close()
default:
continue
}
}
return nil
}

View File

@@ -40,6 +40,10 @@ const (
TypeServerFilesWrite MessageType = "server.files.write"
TypeServerFilesUploadDone MessageType = "server.files.upload.complete"
TypeServerWorldValidate MessageType = "server.world.validate"
TypeServerWorldArchive MessageType = "server.world.archive"
TypeServerWorldReplace MessageType = "server.world.replace"
TypeServerStorageUpload MessageType = "server.storage.upload"
TypeServerStorageDownload MessageType = "server.storage.download"
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
)
@@ -72,6 +76,10 @@ var controlToAgentTypes = map[MessageType]struct{}{
TypeServerFilesWrite: {},
TypeServerFilesUploadDone: {},
TypeServerWorldValidate: {},
TypeServerWorldArchive: {},
TypeServerWorldReplace: {},
TypeServerStorageUpload: {},
TypeServerStorageDownload: {},
TypeServerMetricsSub: {},
TypeServerLogsSubscribe: {},
}
@@ -178,6 +186,40 @@ type ServerFilesWritePayload struct {
Create bool `json:"create"`
}
// ServerWorldValidatePayload validates a Minecraft world directory.
type ServerWorldValidatePayload struct {
OperationMeta
WorldName string `json:"worldName"`
}
// ServerWorldArchivePayload archives a world directory.
type ServerWorldArchivePayload struct {
OperationMeta
WorldName string `json:"worldName"`
ArchiveID string `json:"archiveId"`
}
// ServerStorageUploadPayload uploads a local file to a presigned URL.
type ServerStorageUploadPayload struct {
OperationMeta
LocalPath string `json:"localPath"`
UploadURL string `json:"uploadUrl"`
}
// ServerStorageDownloadPayload downloads a remote file to a local path.
type ServerStorageDownloadPayload struct {
OperationMeta
DownloadURL string `json:"downloadUrl"`
LocalPath string `json:"localPath"`
}
// ServerWorldReplacePayload replaces a world from a local archive.
type ServerWorldReplacePayload struct {
OperationMeta
WorldName string `json:"worldName"`
ArchivePath string `json:"archivePath"`
}
// FileEntry describes a file or directory in a list response.
type FileEntry struct {
Name string `json:"name"`

View File

@@ -5,11 +5,16 @@ import (
"encoding/json"
"errors"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/hexahost/gamecloud/node-agent/internal/archive"
"github.com/hexahost/gamecloud/node-agent/internal/docker"
"github.com/hexahost/gamecloud/node-agent/internal/files"
"github.com/hexahost/gamecloud/node-agent/internal/protocol"
"github.com/hexahost/gamecloud/node-agent/internal/transfer"
worldpkg "github.com/hexahost/gamecloud/node-agent/internal/world"
)
const (
@@ -352,6 +357,219 @@ func (m *Manager) HandleFilesWrite(ctx context.Context, correlationID string, pa
})
}
// HandleBackupPrepare runs save-off and save-all flush via RCON.
func (m *Manager) HandleBackupPrepare(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "backup.prepare", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State != ServerStateRunning {
m.completeOperation(ctx, correlationID, meta, "backup.prepare", map[string]string{"skipped": "not_running"})
return
}
for _, command := range []string{"save-off", "save-all flush"} {
if _, _, err := m.docker.ExecRcon(ctx, rec.ContainerID, command); err != nil {
m.failOperation(ctx, correlationID, meta, "backup.prepare", "BACKUP_PREPARE_FAILED", err.Error())
return
}
}
m.completeOperation(ctx, correlationID, meta, "backup.prepare", map[string]string{"status": "ok"})
}
// HandleBackupRelease runs save-on via RCON.
func (m *Manager) HandleBackupRelease(ctx context.Context, correlationID string, meta protocol.OperationMeta) {
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "backup.release", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State != ServerStateRunning {
m.completeOperation(ctx, correlationID, meta, "backup.release", map[string]string{"skipped": "not_running"})
return
}
if _, _, err := m.docker.ExecRcon(ctx, rec.ContainerID, "save-on"); err != nil {
m.failOperation(ctx, correlationID, meta, "backup.release", "BACKUP_RELEASE_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "backup.release", map[string]string{"status": "ok"})
}
// HandleWorldValidate validates a world directory and lists worlds.
func (m *Manager) HandleWorldValidate(ctx context.Context, correlationID string, payload protocol.ServerWorldValidatePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "world.validate", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
worldName := payload.WorldName
if worldName == "" {
worldName = "world"
}
if err := worldpkg.ValidateWorld(rec.DataPath, worldName); err != nil {
m.failOperation(ctx, correlationID, meta, "world.validate", "WORLD_INVALID", err.Error())
return
}
worlds, err := worldpkg.ListWorlds(rec.DataPath, worldName)
if err != nil {
m.failOperation(ctx, correlationID, meta, "world.validate", "WORLD_LIST_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "world.validate", map[string]any{
"valid": true,
"worldName": worldName,
"worlds": worlds,
})
}
// HandleWorldArchive creates a tar.gz archive of a world directory.
func (m *Manager) HandleWorldArchive(ctx context.Context, correlationID string, payload protocol.ServerWorldArchivePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "world.archive", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
worldName := payload.WorldName
if worldName == "" {
worldName = "world"
}
if err := worldpkg.ValidateWorld(rec.DataPath, worldName); err != nil {
m.failOperation(ctx, correlationID, meta, "world.archive", "WORLD_INVALID", err.Error())
return
}
archiveDir := filepath.Join(rec.DataPath, ".hgc", "archives")
archiveID := payload.ArchiveID
if archiveID == "" {
archiveID = meta.ServerID
}
outputPath := filepath.Join(archiveDir, archiveID+".tar.gz")
sourceDir := filepath.Join(rec.DataPath, worldName)
checksum, size, err := archive.CreateTarGz(sourceDir, outputPath)
if err != nil {
m.failOperation(ctx, correlationID, meta, "world.archive", "ARCHIVE_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "world.archive", map[string]any{
"localPath": outputPath,
"sha256": checksum,
"sizeBytes": size,
"worldName": worldName,
})
}
// HandleStorageUpload uploads a local archive to object storage.
func (m *Manager) HandleStorageUpload(ctx context.Context, correlationID string, payload protocol.ServerStorageUploadPayload) {
meta := payload.OperationMeta
if err := transfer.UploadFile(payload.LocalPath, payload.UploadURL); err != nil {
m.failOperation(ctx, correlationID, meta, "storage.upload", "UPLOAD_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "storage.upload", map[string]string{
"localPath": payload.LocalPath,
})
}
// HandleStorageDownload downloads an archive from object storage.
func (m *Manager) HandleStorageDownload(ctx context.Context, correlationID string, payload protocol.ServerStorageDownloadPayload) {
meta := payload.OperationMeta
if err := transfer.DownloadFile(payload.DownloadURL, payload.LocalPath); err != nil {
m.failOperation(ctx, correlationID, meta, "storage.download", "DOWNLOAD_FAILED", err.Error())
return
}
m.completeOperation(ctx, correlationID, meta, "storage.download", map[string]string{
"localPath": payload.LocalPath,
})
}
// HandleWorldReplace replaces a world directory from a local archive.
func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string, payload protocol.ServerWorldReplacePayload) {
meta := payload.OperationMeta
rec, ok := m.getServer(meta.ServerID)
if !ok {
m.failOperation(ctx, correlationID, meta, "world.replace", "SERVER_NOT_FOUND", "server is not provisioned on this node")
return
}
if rec.State == ServerStateRunning {
m.failOperation(ctx, correlationID, meta, "world.replace", "SERVER_RUNNING", "server must be stopped before world replace")
return
}
worldName := payload.WorldName
if worldName == "" {
worldName = "world"
}
worldDir := filepath.Join(rec.DataPath, worldName)
tempDir := filepath.Join(rec.DataPath, ".hgc", "restore", meta.ServerID)
if err := os.RemoveAll(tempDir); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "RESTORE_PREP_FAILED", err.Error())
return
}
if err := os.MkdirAll(tempDir, 0o755); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "RESTORE_PREP_FAILED", err.Error())
return
}
if err := archive.ExtractTarGz(payload.ArchivePath, tempDir); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "EXTRACT_FAILED", err.Error())
return
}
extractedWorld := filepath.Join(tempDir, worldName)
if info, err := os.Stat(extractedWorld); err != nil || !info.IsDir() {
extractedWorld = tempDir
}
if err := worldpkg.ValidateWorld(filepath.Dir(extractedWorld), filepath.Base(extractedWorld)); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "WORLD_INVALID", err.Error())
return
}
backupDir := worldDir + ".bak"
_ = os.RemoveAll(backupDir)
if _, err := os.Stat(worldDir); err == nil {
if err := os.Rename(worldDir, backupDir); err != nil {
m.failOperation(ctx, correlationID, meta, "world.replace", "REPLACE_FAILED", err.Error())
return
}
}
if err := os.Rename(extractedWorld, worldDir); err != nil {
_ = os.Rename(backupDir, worldDir)
m.failOperation(ctx, correlationID, meta, "world.replace", "REPLACE_FAILED", err.Error())
return
}
_ = os.RemoveAll(tempDir)
_ = os.RemoveAll(backupDir)
m.completeOperation(ctx, correlationID, meta, "world.replace", map[string]string{
"worldName": worldName,
})
}
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
m.mu.RLock()
defer m.mu.RUnlock()

View File

@@ -0,0 +1,73 @@
package transfer
import (
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
)
// UploadFile PUTs a local file to a presigned URL.
func UploadFile(localPath, uploadURL string) error {
file, err := os.Open(localPath)
if err != nil {
return err
}
defer file.Close()
stat, err := file.Stat()
if err != nil {
return err
}
request, err := http.NewRequest(http.MethodPut, uploadURL, file)
if err != nil {
return err
}
request.ContentLength = stat.Size()
request.Header.Set("Content-Type", "application/gzip")
client := &http.Client{Timeout: 30 * time.Minute}
response, err := client.Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("upload failed with status %d: %s", response.StatusCode, string(body))
}
return nil
}
// DownloadFile GETs a remote file to a local path.
func DownloadFile(downloadURL, localPath string) error {
if err := os.MkdirAll(filepath.Dir(localPath), 0o755); err != nil {
return err
}
client := &http.Client{Timeout: 30 * time.Minute}
response, err := client.Get(downloadURL)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(response.Body, 4096))
return fmt.Errorf("download failed with status %d: %s", response.StatusCode, string(body))
}
file, err := os.Create(localPath)
if err != nil {
return err
}
defer file.Close()
_, err = io.Copy(file, response.Body)
return err
}

View File

@@ -0,0 +1,114 @@
package world
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// Summary describes a discovered Minecraft world directory.
type Summary struct {
Name string `json:"name"`
Path string `json:"path"`
SizeBytes int64 `json:"sizeBytes"`
HasLevelDat bool `json:"hasLevelDat"`
}
// ValidateWorld checks that a world directory contains level.dat.
func ValidateWorld(dataPath, worldName string) error {
worldName = strings.TrimSpace(worldName)
if worldName == "" || strings.Contains(worldName, "..") || strings.ContainsAny(worldName, `/\`) {
return fmt.Errorf("invalid world name")
}
worldDir := filepath.Join(dataPath, worldName)
levelDat := filepath.Join(worldDir, "level.dat")
info, err := os.Stat(levelDat)
if err != nil {
return fmt.Errorf("level.dat not found in world directory")
}
if info.IsDir() {
return fmt.Errorf("level.dat is not a file")
}
return nil
}
// ListWorlds scans the server data directory for world folders.
func ListWorlds(dataPath, activeWorldName string) ([]Summary, error) {
entries, err := os.ReadDir(dataPath)
if err != nil {
return nil, err
}
worlds := make([]Summary, 0)
for _, entry := range entries {
if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") {
continue
}
worldPath := filepath.Join(dataPath, entry.Name())
levelDat := filepath.Join(worldPath, "level.dat")
hasLevelDat := false
if info, err := os.Stat(levelDat); err == nil && !info.IsDir() {
hasLevelDat = true
}
size, err := dirSize(worldPath)
if err != nil {
return nil, err
}
worlds = append(worlds, Summary{
Name: entry.Name(),
Path: entry.Name(),
SizeBytes: size,
HasLevelDat: hasLevelDat,
})
}
if activeWorldName != "" {
found := false
for _, world := range worlds {
if world.Name == activeWorldName {
found = true
break
}
}
if !found {
if err := ValidateWorld(dataPath, activeWorldName); err == nil {
size, sizeErr := dirSize(filepath.Join(dataPath, activeWorldName))
if sizeErr != nil {
return nil, sizeErr
}
worlds = append(worlds, Summary{
Name: activeWorldName,
Path: activeWorldName,
SizeBytes: size,
HasLevelDat: true,
})
}
}
}
return worlds, nil
}
func dirSize(root string) (int64, error) {
var total int64
err := filepath.Walk(root, func(_ string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
return total, err
}

View File

@@ -314,6 +314,62 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
}
c.runtime.HandleFilesWrite(ctx, env.MessageID, payload)
case protocol.TypeServerBackupPrepare:
var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil {
c.log.Warn("decode server.backup.prepare", "error", err)
return
}
c.runtime.HandleBackupPrepare(ctx, env.MessageID, meta)
case protocol.TypeServerBackupRelease:
var meta protocol.OperationMeta
if err := json.Unmarshal(env.Payload, &meta); err != nil {
c.log.Warn("decode server.backup.release", "error", err)
return
}
c.runtime.HandleBackupRelease(ctx, env.MessageID, meta)
case protocol.TypeServerWorldValidate:
var payload protocol.ServerWorldValidatePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.world.validate", "error", err)
return
}
c.runtime.HandleWorldValidate(ctx, env.MessageID, payload)
case protocol.TypeServerWorldArchive:
var payload protocol.ServerWorldArchivePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.world.archive", "error", err)
return
}
c.runtime.HandleWorldArchive(ctx, env.MessageID, payload)
case protocol.TypeServerStorageUpload:
var payload protocol.ServerStorageUploadPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.storage.upload", "error", err)
return
}
c.runtime.HandleStorageUpload(ctx, env.MessageID, payload)
case protocol.TypeServerStorageDownload:
var payload protocol.ServerStorageDownloadPayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.storage.download", "error", err)
return
}
c.runtime.HandleStorageDownload(ctx, env.MessageID, payload)
case protocol.TypeServerWorldReplace:
var payload protocol.ServerWorldReplacePayload
if err := json.Unmarshal(env.Payload, &payload); err != nil {
c.log.Warn("decode server.world.replace", "error", err)
return
}
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
default:
c.log.Debug("ignored control message", "type", env.Type)
}

View File

@@ -190,7 +190,9 @@
"console": "Konsole",
"files": "Dateien",
"properties": "Eigenschaften",
"players": "Spieler"
"players": "Spieler",
"worlds": "Welten",
"backups": "Backups"
},
"console": {
"loading": "Wird geladen…",
@@ -274,6 +276,47 @@
"errors": {
"loadFailed": "Spielerliste konnte nicht geladen werden."
}
},
"worlds": {
"loading": "Wird geladen…",
"loadError": "Weltinformationen konnten nicht geladen werden.",
"activeWorld": "Aktive Welt: {name}",
"stoppedHint": "Upload und Ersetzen erfordern einen gestoppten Server.",
"worldName": "Weltname",
"uploadArchive": "Welt-Archiv (.tar.gz)",
"uploadError": "Welt-Upload fehlgeschlagen.",
"download": "Welt herunterladen",
"downloadError": "Download konnte nicht gestartet werden.",
"downloadPending": "Backup wird erstellt. Versuchen Sie den Download in Kürze erneut.",
"empty": "Keine Welten gefunden.",
"validWorld": "Gültige Welt",
"invalidWorld": "Ungültig",
"active": "Aktiv"
},
"backups": {
"loading": "Wird geladen…",
"loadError": "Backups konnten nicht geladen werden.",
"create": "Backup erstellen",
"manualLabel": "Manuelles Backup",
"createError": "Backup konnte nicht erstellt werden.",
"download": "Download",
"downloadError": "Download-URL konnte nicht erstellt werden.",
"restore": "Wiederherstellen",
"restoreConfirm": "Welt wirklich aus diesem Backup wiederherstellen? Der Server muss gestoppt sein.",
"restoreError": "Wiederherstellung fehlgeschlagen.",
"delete": "Löschen",
"deleteConfirm": "Backup wirklich löschen?",
"deleteError": "Backup konnte nicht gelöscht werden.",
"enableSchedule": "Geplante Backups aktivieren",
"disableSchedule": "Geplante Backups deaktivieren",
"scheduleError": "Zeitplan konnte nicht aktualisiert werden.",
"scheduleInfo": "Intervall: alle {hours}h · Aufbewahrung: {retention} Backups",
"label": "Bezeichnung",
"status": "Status",
"size": "Größe",
"created": "Erstellt",
"actions": "Aktionen",
"empty": "Noch keine Backups vorhanden."
}
}
}

View File

@@ -190,7 +190,9 @@
"console": "Console",
"files": "Files",
"properties": "Properties",
"players": "Players"
"players": "Players",
"worlds": "Worlds",
"backups": "Backups"
},
"console": {
"loading": "Loading…",
@@ -274,6 +276,47 @@
"errors": {
"loadFailed": "Could not load player list."
}
},
"worlds": {
"loading": "Loading…",
"loadError": "Could not load world information.",
"activeWorld": "Active world: {name}",
"stoppedHint": "Upload and replace require a stopped server.",
"worldName": "World name",
"uploadArchive": "World archive (.tar.gz)",
"uploadError": "World upload failed.",
"download": "Download world",
"downloadError": "Could not start download.",
"downloadPending": "Backup is being created. Try the download again shortly.",
"empty": "No worlds found.",
"validWorld": "Valid world",
"invalidWorld": "Invalid",
"active": "Active"
},
"backups": {
"loading": "Loading…",
"loadError": "Could not load backups.",
"create": "Create backup",
"manualLabel": "Manual backup",
"createError": "Could not create backup.",
"download": "Download",
"downloadError": "Could not create download URL.",
"restore": "Restore",
"restoreConfirm": "Restore the world from this backup? The server must be stopped.",
"restoreError": "Restore failed.",
"delete": "Delete",
"deleteConfirm": "Delete this backup?",
"deleteError": "Could not delete backup.",
"enableSchedule": "Enable scheduled backups",
"disableSchedule": "Disable scheduled backups",
"scheduleError": "Could not update schedule.",
"scheduleInfo": "Interval: every {hours}h · Retention: {retention} backups",
"label": "Label",
"status": "Status",
"size": "Size",
"created": "Created",
"actions": "Actions",
"empty": "No backups yet."
}
}
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerBackups } from "@/components/servers/server-backups";
interface ServerBackupsPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerBackupsPage({ params }: ServerBackupsPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerBackups serverId={id} />;
}

View File

@@ -0,0 +1,13 @@
import { setRequestLocale } from "next-intl/server";
import { ServerWorlds } from "@/components/servers/server-worlds";
interface ServerWorldsPageProps {
params: Promise<{ locale: string; id: string }>;
}
export default async function ServerWorldsPage({ params }: ServerWorldsPageProps) {
const { locale, id } = await params;
setRequestLocale(locale);
return <ServerWorlds serverId={id} />;
}

View File

@@ -0,0 +1,237 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
createBackup,
deleteBackup,
getBackupDownloadUrl,
getBackupSchedule,
listBackups,
restoreBackup,
updateBackupSchedule,
type BackupItem,
type BackupSchedule,
} from "@/lib/api/backups";
interface ServerBackupsProps {
serverId: string;
}
function formatBytes(value: string | null): string {
if (!value) {
return "—";
}
const bytes = Number(value);
if (Number.isNaN(bytes)) {
return value;
}
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function ServerBackups({ serverId }: ServerBackupsProps) {
const t = useTranslations("servers.backups");
const [backups, setBackups] = useState<BackupItem[]>([]);
const [schedule, setSchedule] = useState<BackupSchedule | null>(null);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const [backupList, scheduleData] = await Promise.all([
listBackups(serverId),
getBackupSchedule(serverId),
]);
setBackups(backupList.backups);
setSchedule(scheduleData);
} catch (err) {
setError(err instanceof Error ? err.message : t("loadError"));
} finally {
setLoading(false);
}
}, [serverId, t]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleCreateBackup() {
setBusy(true);
setError(null);
try {
await createBackup(serverId, t("manualLabel"));
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("createError"));
} finally {
setBusy(false);
}
}
async function handleDownload(backupId: string) {
try {
const { downloadUrl } = await getBackupDownloadUrl(serverId, backupId);
window.open(downloadUrl, "_blank", "noopener,noreferrer");
} catch (err) {
setError(err instanceof Error ? err.message : t("downloadError"));
}
}
async function handleRestore(backupId: string) {
if (!window.confirm(t("restoreConfirm"))) {
return;
}
setBusy(true);
try {
await restoreBackup(serverId, backupId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("restoreError"));
} finally {
setBusy(false);
}
}
async function handleDelete(backupId: string) {
if (!window.confirm(t("deleteConfirm"))) {
return;
}
setBusy(true);
try {
await deleteBackup(serverId, backupId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("deleteError"));
} finally {
setBusy(false);
}
}
async function toggleSchedule(enabled: boolean) {
if (!schedule) {
return;
}
setBusy(true);
try {
const updated = await updateBackupSchedule(serverId, { enabled });
setSchedule(updated);
} catch (err) {
setError(err instanceof Error ? err.message : t("scheduleError"));
} finally {
setBusy(false);
}
}
if (loading) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<div className="flex flex-wrap items-center gap-3">
<Button onClick={() => void handleCreateBackup()} disabled={busy}>
{t("create")}
</Button>
{schedule ? (
<Button
variant="secondary"
onClick={() => void toggleSchedule(!schedule.enabled)}
disabled={busy}
>
{schedule.enabled ? t("disableSchedule") : t("enableSchedule")}
</Button>
) : null}
</div>
{schedule ? (
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{t("scheduleInfo", {
hours: schedule.intervalHours,
retention: schedule.retentionCount,
})}
</p>
) : null}
<div className="overflow-x-auto rounded-lg border border-zinc-200 dark:border-zinc-800">
<table className="min-w-full divide-y divide-zinc-200 text-sm dark:divide-zinc-800">
<thead className="bg-zinc-50 dark:bg-zinc-900">
<tr>
<th className="px-4 py-2 text-left font-medium">{t("label")}</th>
<th className="px-4 py-2 text-left font-medium">{t("status")}</th>
<th className="px-4 py-2 text-left font-medium">{t("size")}</th>
<th className="px-4 py-2 text-left font-medium">{t("created")}</th>
<th className="px-4 py-2 text-right font-medium">{t("actions")}</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-200 dark:divide-zinc-800">
{backups.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-6 text-center text-zinc-500">
{t("empty")}
</td>
</tr>
) : (
backups.map((backup) => (
<tr key={backup.id}>
<td className="px-4 py-2">{backup.label ?? backup.type}</td>
<td className="px-4 py-2">{backup.status}</td>
<td className="px-4 py-2">{formatBytes(backup.sizeBytes)}</td>
<td className="px-4 py-2">
{new Date(backup.createdAt).toLocaleString()}
</td>
<td className="px-4 py-2 text-right space-x-2">
{backup.status === "AVAILABLE" ? (
<>
<Button
size="sm"
variant="secondary"
onClick={() => void handleDownload(backup.id)}
>
{t("download")}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => void handleRestore(backup.id)}
disabled={busy}
>
{t("restore")}
</Button>
</>
) : null}
<Button
size="sm"
variant="ghost"
onClick={() => void handleDelete(backup.id)}
disabled={busy}
>
{t("delete")}
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -13,6 +13,8 @@ const tabs = [
{ key: "files", suffix: "/files" },
{ key: "properties", suffix: "/properties" },
{ key: "players", suffix: "/players" },
{ key: "worlds", suffix: "/worlds" },
{ key: "backups", suffix: "/backups" },
] as const;
export function ServerTabs({ serverId }: ServerTabsProps) {

View File

@@ -0,0 +1,172 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@hexahost/ui";
import {
completeWorldUpload,
getWorldInfo,
initiateWorldUpload,
requestWorldDownload,
type WorldSummary,
} from "@/lib/api/worlds";
import { getBackupDownloadUrl } from "@/lib/api/backups";
interface ServerWorldsProps {
serverId: string;
}
function formatBytes(value: string): string {
const bytes = Number(value);
if (Number.isNaN(bytes)) {
return value;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function ServerWorlds({ serverId }: ServerWorldsProps) {
const t = useTranslations("servers.worlds");
const [activeWorldName, setActiveWorldName] = useState("world");
const [worlds, setWorlds] = useState<WorldSummary[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [worldName, setWorldName] = useState("world");
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const info = await getWorldInfo(serverId);
setActiveWorldName(info.activeWorldName);
setWorlds(info.worlds);
} catch (err) {
setError(err instanceof Error ? err.message : t("loadError"));
} finally {
setLoading(false);
}
}, [serverId, t]);
useEffect(() => {
void refresh();
}, [refresh]);
async function handleUpload(file: File) {
setBusy(true);
setError(null);
try {
const init = await initiateWorldUpload(serverId, worldName);
const response = await fetch(init.uploadUrl, {
method: "PUT",
body: file,
headers: { "Content-Type": "application/gzip" },
});
if (!response.ok) {
throw new Error(t("uploadError"));
}
await completeWorldUpload(serverId, init.uploadId);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : t("uploadError"));
} finally {
setBusy(false);
}
}
async function handleDownload() {
setBusy(true);
setError(null);
try {
const { backupId } = await requestWorldDownload(serverId);
const { downloadUrl } = await getBackupDownloadUrl(serverId, backupId);
if (downloadUrl) {
window.open(downloadUrl, "_blank", "noopener,noreferrer");
} else {
setError(t("downloadPending"));
}
} catch (err) {
setError(err instanceof Error ? err.message : t("downloadError"));
} finally {
setBusy(false);
}
}
if (loading) {
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
}
return (
<div className="space-y-6">
{error ? (
<p className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-900 dark:bg-red-950 dark:text-red-300">
{error}
</p>
) : null}
<div className="rounded-lg border border-zinc-200 p-4 dark:border-zinc-800">
<p className="text-sm text-zinc-600 dark:text-zinc-400">
{t("activeWorld", { name: activeWorldName })}
</p>
<p className="mt-1 text-xs text-zinc-500">{t("stoppedHint")}</p>
</div>
<div className="flex flex-wrap gap-3 items-end">
<label className="block text-sm">
<span className="mb-1 block text-zinc-600 dark:text-zinc-400">
{t("worldName")}
</span>
<input
className="rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
value={worldName}
onChange={(event) => setWorldName(event.target.value)}
/>
</label>
<label className="block text-sm">
<span className="mb-1 block text-zinc-600 dark:text-zinc-400">
{t("uploadArchive")}
</span>
<input
type="file"
accept=".tar.gz,.tgz,application/gzip"
disabled={busy}
onChange={(event) => {
const file = event.target.files?.[0];
if (file) {
void handleUpload(file);
}
}}
/>
</label>
<Button variant="secondary" onClick={() => void handleDownload()} disabled={busy}>
{t("download")}
</Button>
</div>
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
{worlds.length === 0 ? (
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
) : (
worlds.map((world) => (
<li key={world.name} className="flex items-center justify-between px-4 py-3 text-sm">
<div>
<p className="font-medium">{world.name}</p>
<p className="text-zinc-500">
{world.hasLevelDat ? t("validWorld") : t("invalidWorld")} ·{" "}
{formatBytes(world.sizeBytes)}
</p>
</div>
{world.name === activeWorldName ? (
<span className="text-xs text-sky-600 dark:text-sky-400">{t("active")}</span>
) : null}
</li>
))
)}
</ul>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { apiFetch } from "./auth";
export interface BackupItem {
id: string;
serverId: string;
type: string;
status: string;
sizeBytes: string | null;
sha256: string | null;
label: string | null;
failureReason: string | null;
createdAt: string;
completedAt: string | null;
}
export interface BackupListResponse {
backups: BackupItem[];
}
export interface BackupSchedule {
enabled: boolean;
intervalHours: number;
retentionCount: number;
nextRunAt: string | null;
lastRunAt: string | null;
}
export async function listBackups(serverId: string): Promise<BackupListResponse> {
return apiFetch<BackupListResponse>(`/servers/${serverId}/backups`);
}
export async function createBackup(
serverId: string,
label?: string,
): Promise<{ backup: BackupItem; jobId?: string }> {
return apiFetch(`/servers/${serverId}/backups`, {
method: "POST",
body: JSON.stringify({ label }),
});
}
export async function deleteBackup(
serverId: string,
backupId: string,
): Promise<BackupItem> {
return apiFetch(`/servers/${serverId}/backups/${backupId}`, {
method: "DELETE",
});
}
export async function getBackupDownloadUrl(
serverId: string,
backupId: string,
): Promise<{ downloadUrl: string; expiresAt: string }> {
return apiFetch(`/servers/${serverId}/backups/${backupId}/download-url`);
}
export async function restoreBackup(
serverId: string,
backupId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/backups/${backupId}/restore`, {
method: "POST",
body: JSON.stringify({ confirm: true }),
});
}
export async function getBackupSchedule(serverId: string): Promise<BackupSchedule> {
return apiFetch(`/servers/${serverId}/backups/schedule`);
}
export async function updateBackupSchedule(
serverId: string,
input: Partial<BackupSchedule>,
): Promise<BackupSchedule> {
return apiFetch(`/servers/${serverId}/backups/schedule`, {
method: "PATCH",
body: JSON.stringify(input),
});
}

View File

@@ -0,0 +1,48 @@
import { apiFetch } from "./auth";
export interface WorldSummary {
name: string;
path: string;
sizeBytes: string;
hasLevelDat: boolean;
}
export interface WorldInfoResponse {
activeWorldName: string;
worlds: WorldSummary[];
}
export async function getWorldInfo(serverId: string): Promise<WorldInfoResponse> {
return apiFetch(`/servers/${serverId}/worlds`);
}
export async function initiateWorldUpload(
serverId: string,
worldName: string,
): Promise<{
uploadId: string;
uploadUrl: string;
expiresAt: string;
}> {
return apiFetch(`/servers/${serverId}/worlds/uploads`, {
method: "POST",
body: JSON.stringify({ worldName }),
});
}
export async function completeWorldUpload(
serverId: string,
uploadId: string,
): Promise<unknown> {
return apiFetch(`/servers/${serverId}/worlds/uploads/${uploadId}/complete`, {
method: "POST",
});
}
export async function requestWorldDownload(
serverId: string,
): Promise<{ backupId: string }> {
return apiFetch(`/servers/${serverId}/worlds/download`, {
method: "POST",
});
}

View File

@@ -11,9 +11,10 @@
"typecheck": "tsc --noEmit",
"test": "node --test test/**/*.test.js"
},
"dependencies": {
"dependencies": {
"@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*",
"@hexahost/storage": "workspace:*",
"bullmq": "^5.34.10",
"ioredis": "^5.4.2",
"nodemailer": "^6.10.0",

View File

@@ -0,0 +1,54 @@
import { randomUUID } from 'node:crypto';
import { validateConfig } from '@hexahost/config';
import { logger } from './logger';
import {
publishNodeCommand,
waitForNodeResponse,
type NodeResponseMessage,
} from './redis';
export interface AgentRequestResult extends NodeResponseMessage {
result?: unknown;
}
export async function sendAgentRequest(
nodeId: string,
type: string,
payload: Record<string, unknown>,
timeoutMs = 120_000,
): Promise<AgentRequestResult> {
const messageId = randomUUID();
await publishNodeCommand({
nodeId,
envelope: {
protocolVersion: 1,
messageId,
type,
timestamp: new Date().toISOString(),
payload: {
...payload,
generation: payload['generation'] ?? 0,
},
},
});
const response = await waitForNodeResponse(messageId, timeoutMs);
if (response === null) {
logger.error({ nodeId, type, messageId }, 'Agent request timed out');
return {
success: false,
errorCode: 'AGENT_TIMEOUT',
errorMessage: 'Agent request timed out',
};
}
return response as AgentRequestResult;
}
export function loadStorageEnv(): void {
validateConfig();
}

View File

@@ -0,0 +1,632 @@
import { Job } from 'bullmq';
import { z } from 'zod';
import { prisma } from '@hexahost/database';
import {
backupObjectKey,
createPresignedGetUrl,
createPresignedPutUrl,
createS3Client,
deleteStoredObject,
loadStorageConfig,
} from '@hexahost/storage';
import { sendAgentRequest } from '../agent-bridge';
import { logger } from '../logger';
import { transitionServerStatus } from '../server-state';
import { getBackupsQueue } from '../queues';
const BACKUP_TIMEOUT_MS = 20 * 60 * 1000;
const createBackupJobSchema = z.object({
backupId: z.string().uuid(),
serverId: z.string().uuid(),
previousStatus: z.enum(['RUNNING', 'STOPPED']),
});
const restoreBackupJobSchema = z.object({
restoreId: z.string().uuid(),
serverId: z.string().uuid(),
backupId: z.string().uuid(),
});
const applyWorldUploadJobSchema = z.object({
uploadId: z.string().uuid(),
serverId: z.string().uuid(),
});
const retentionJobSchema = z.object({
serverId: z.string().uuid(),
});
interface ArchiveResult {
localPath: string;
sha256: string;
sizeBytes: number;
worldName: string;
}
function getStorage() {
const config = loadStorageConfig();
return {
config,
client: createS3Client(config),
};
}
async function loadServer(serverId: string) {
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
include: { backupSchedule: true },
});
if (!server?.nodeId) {
throw new Error(`Server ${serverId} is not assigned to a node`);
}
return server;
}
async function markBackupFailed(
backupId: string,
reason: string,
): Promise<void> {
await prisma.serverBackup.update({
where: { id: backupId },
data: {
status: 'FAILED',
failureReason: reason,
completedAt: new Date(),
},
});
}
async function runBackupArchive(
serverId: string,
nodeId: string,
backupId: string,
worldName: string,
generation: number,
): Promise<ArchiveResult> {
const response = await sendAgentRequest(
nodeId,
'server.world.archive',
{
serverId,
generation,
worldName,
archiveId: backupId,
},
BACKUP_TIMEOUT_MS,
);
if (!response.success) {
throw new Error(response.errorMessage ?? 'World archive failed');
}
return response.result as ArchiveResult;
}
async function uploadArchiveToS3(
serverId: string,
nodeId: string,
backupId: string,
archive: ArchiveResult,
generation: number,
): Promise<void> {
const { config, client } = getStorage();
const s3Key = backupObjectKey(serverId, backupId);
const uploadUrl = await createPresignedPutUrl(
client,
config.S3_BUCKET,
s3Key,
60 * 60,
);
const response = await sendAgentRequest(
nodeId,
'server.storage.upload',
{
serverId,
generation,
localPath: archive.localPath,
uploadUrl,
},
BACKUP_TIMEOUT_MS,
);
if (!response.success) {
throw new Error(response.errorMessage ?? 'Backup upload failed');
}
await prisma.serverBackup.update({
where: { id: backupId },
data: {
s3Key,
sha256: archive.sha256,
sizeBytes: BigInt(archive.sizeBytes),
},
});
}
async function enforceRetention(serverId: string, retentionCount: number): Promise<void> {
const backups = await prisma.serverBackup.findMany({
where: {
serverId,
status: 'AVAILABLE',
type: { in: ['MANUAL', 'SCHEDULED'] },
},
orderBy: { createdAt: 'desc' },
});
const extras = backups.slice(retentionCount);
if (extras.length === 0) {
return;
}
const { config, client } = getStorage();
for (const backup of extras) {
if (backup.s3Key) {
try {
await deleteStoredObject(client, config.S3_BUCKET, backup.s3Key);
} catch (error) {
logger.warn({ backupId: backup.id, error }, 'Failed to delete S3 backup object');
}
}
await prisma.serverBackup.update({
where: { id: backup.id },
data: { status: 'DELETED' },
});
}
}
export async function processCreateBackupJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'create-backup') {
return { status: 'ignored' };
}
const { backupId, serverId, previousStatus } = createBackupJobSchema.parse(job.data);
const server = await loadServer(serverId);
await prisma.serverBackup.update({
where: { id: backupId },
data: { status: 'RUNNING' },
});
try {
if (previousStatus === 'RUNNING') {
const prepare = await sendAgentRequest(
server.nodeId!,
'server.backup.prepare',
{ serverId, generation: server.version },
BACKUP_TIMEOUT_MS,
);
if (!prepare.success) {
throw new Error(prepare.errorMessage ?? 'Backup prepare failed');
}
}
const archive = await runBackupArchive(
serverId,
server.nodeId!,
backupId,
server.activeWorldName,
server.version,
);
await uploadArchiveToS3(
serverId,
server.nodeId!,
backupId,
archive,
server.version,
);
if (previousStatus === 'RUNNING') {
const release = await sendAgentRequest(
server.nodeId!,
'server.backup.release',
{ serverId, generation: server.version },
BACKUP_TIMEOUT_MS,
);
if (!release.success) {
throw new Error(release.errorMessage ?? 'Backup release failed');
}
}
await prisma.serverBackup.update({
where: { id: backupId },
data: {
status: 'AVAILABLE',
completedAt: new Date(),
},
});
await transitionServerStatus(serverId, previousStatus, {
expectedFrom: 'BACKING_UP',
reason: 'Backup completed',
});
const retentionCount = server.backupSchedule?.retentionCount ?? 5;
await enforceRetention(serverId, retentionCount);
return { status: 'available' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Backup failed';
await markBackupFailed(backupId, message);
try {
if (previousStatus === 'RUNNING') {
await sendAgentRequest(
server.nodeId!,
'server.backup.release',
{ serverId, generation: server.version },
60_000,
);
}
await transitionServerStatus(serverId, previousStatus, {
expectedFrom: 'BACKING_UP',
reason: 'Backup failed',
errorMessage: message,
});
} catch (restoreError) {
logger.error({ restoreError, serverId }, 'Failed to restore server status after backup error');
await transitionServerStatus(serverId, 'ERROR', {
expectedFrom: 'BACKING_UP',
reason: 'Backup failed and status recovery failed',
errorMessage: message,
});
}
throw error;
}
}
export async function processRestoreBackupJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'restore-backup') {
return { status: 'ignored' };
}
const { restoreId, serverId, backupId } = restoreBackupJobSchema.parse(job.data);
const server = await loadServer(serverId);
const backup = await prisma.serverBackup.findUniqueOrThrow({
where: { id: backupId },
});
if (backup.status !== 'AVAILABLE' || !backup.s3Key) {
throw new Error('Backup is not available for restore');
}
await prisma.backupRestore.update({
where: { id: restoreId },
data: { status: 'RUNNING' },
});
const safetyBackup = await prisma.serverBackup.create({
data: {
serverId,
type: 'PRE_RESTORE',
status: 'RUNNING',
label: 'Pre-restore safety backup',
},
});
await prisma.backupRestore.update({
where: { id: restoreId },
data: { safetyBackupId: safetyBackup.id },
});
try {
const safetyArchive = await runBackupArchive(
serverId,
server.nodeId!,
safetyBackup.id,
server.activeWorldName,
server.version,
);
await uploadArchiveToS3(
serverId,
server.nodeId!,
safetyBackup.id,
safetyArchive,
server.version,
);
await prisma.serverBackup.update({
where: { id: safetyBackup.id },
data: {
status: 'AVAILABLE',
completedAt: new Date(),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Safety backup failed';
await markBackupFailed(safetyBackup.id, message);
throw error;
}
const { config, client } = getStorage();
const downloadUrl = await createPresignedGetUrl(
client,
config.S3_BUCKET,
backup.s3Key,
60 * 60,
);
const localArchive = `/var/lib/hgc/servers/${serverId}/.hgc/restore/${restoreId}.tar.gz`;
try {
const download = await sendAgentRequest(
server.nodeId!,
'server.storage.download',
{
serverId,
generation: server.version,
downloadUrl,
localPath: localArchive,
},
BACKUP_TIMEOUT_MS,
);
if (!download.success) {
throw new Error(download.errorMessage ?? 'Backup download failed');
}
const replace = await sendAgentRequest(
server.nodeId!,
'server.world.replace',
{
serverId,
generation: server.version,
worldName: server.activeWorldName,
archivePath: localArchive,
},
BACKUP_TIMEOUT_MS,
);
if (!replace.success) {
throw new Error(replace.errorMessage ?? 'World replace failed');
}
await prisma.backupRestore.update({
where: { id: restoreId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
});
await transitionServerStatus(serverId, 'STOPPED', {
expectedFrom: 'RESTORING',
reason: 'Restore completed',
});
return { status: 'completed' };
} catch (error) {
const message = error instanceof Error ? error.message : 'Restore failed';
await prisma.backupRestore.update({
where: { id: restoreId },
data: {
status: 'FAILED',
failureReason: message,
completedAt: new Date(),
},
});
await transitionServerStatus(serverId, 'STOPPED', {
expectedFrom: 'RESTORING',
reason: 'Restore failed',
errorMessage: message,
});
throw error;
}
}
export async function processApplyWorldUploadJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'apply-world-upload') {
return { status: 'ignored' };
}
const { uploadId, serverId } = applyWorldUploadJobSchema.parse(job.data);
const server = await loadServer(serverId);
const upload = await prisma.worldUpload.findUniqueOrThrow({
where: { id: uploadId },
});
await prisma.worldUpload.update({
where: { id: uploadId },
data: { status: 'VALIDATING' },
});
const safetyBackup = await prisma.serverBackup.create({
data: {
serverId,
type: 'PRE_WORLD_REPLACE',
status: 'RUNNING',
label: `Pre-replace backup for upload ${uploadId}`,
},
});
try {
const safetyArchive = await runBackupArchive(
serverId,
server.nodeId!,
safetyBackup.id,
server.activeWorldName,
server.version,
);
await uploadArchiveToS3(
serverId,
server.nodeId!,
safetyBackup.id,
safetyArchive,
server.version,
);
await prisma.serverBackup.update({
where: { id: safetyBackup.id },
data: {
status: 'AVAILABLE',
completedAt: new Date(),
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Safety backup failed';
await markBackupFailed(safetyBackup.id, message);
throw error;
}
const { config, client } = getStorage();
const downloadUrl = await createPresignedGetUrl(
client,
config.S3_BUCKET,
upload.s3Key,
60 * 60,
);
const localArchive = `/var/lib/hgc/servers/${serverId}/.hgc/uploads/${uploadId}.tar.gz`;
try {
const download = await sendAgentRequest(
server.nodeId!,
'server.storage.download',
{
serverId,
generation: server.version,
downloadUrl,
localPath: localArchive,
},
BACKUP_TIMEOUT_MS,
);
if (!download.success) {
throw new Error(download.errorMessage ?? 'Upload download failed');
}
const replace = await sendAgentRequest(
server.nodeId!,
'server.world.replace',
{
serverId,
generation: server.version,
worldName: upload.worldName,
archivePath: localArchive,
},
BACKUP_TIMEOUT_MS,
);
if (!replace.success) {
throw new Error(replace.errorMessage ?? 'World replace failed');
}
await prisma.$transaction([
prisma.worldUpload.update({
where: { id: uploadId },
data: {
status: 'COMPLETED',
completedAt: new Date(),
},
}),
prisma.gameServer.update({
where: { id: serverId },
data: { activeWorldName: upload.worldName },
}),
]);
return { status: 'completed' };
} catch (error) {
const message = error instanceof Error ? error.message : 'World upload apply failed';
await prisma.worldUpload.update({
where: { id: uploadId },
data: {
status: 'FAILED',
failureReason: message,
completedAt: new Date(),
},
});
throw error;
}
}
export async function processRetentionJob(job: Job): Promise<{ status: string }> {
if (job.name !== 'enforce-retention') {
return { status: 'ignored' };
}
const { serverId } = retentionJobSchema.parse(job.data);
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
include: { backupSchedule: true },
});
if (!server) {
return { status: 'ignored' };
}
await enforceRetention(serverId, server.backupSchedule?.retentionCount ?? 5);
return { status: 'ok' };
}
export async function processScheduledBackupsTick(): Promise<void> {
const now = new Date();
const schedules = await prisma.backupSchedule.findMany({
where: {
enabled: true,
OR: [{ nextRunAt: null }, { nextRunAt: { lte: now } }],
},
});
for (const schedule of schedules) {
const server = await prisma.gameServer.findUnique({
where: { id: schedule.serverId },
});
if (!server?.nodeId || !['RUNNING', 'STOPPED'].includes(server.status)) {
continue;
}
const backup = await prisma.serverBackup.create({
data: {
serverId: schedule.serverId,
type: 'SCHEDULED',
status: 'PENDING',
label: 'Scheduled backup',
},
});
await transitionServerStatus(schedule.serverId, 'BACKING_UP', {
expectedFrom: server.status as 'RUNNING' | 'STOPPED',
reason: 'Scheduled backup started',
});
await getBackupsQueue().add('create-backup', {
backupId: backup.id,
serverId: schedule.serverId,
previousStatus: server.status,
});
const nextRunAt = new Date(now.getTime() + schedule.intervalHours * 60 * 60 * 1000);
await prisma.backupSchedule.update({
where: { id: schedule.id },
data: {
lastRunAt: now,
nextRunAt,
},
});
}
}
export async function processBackupJob(job: Job): Promise<{ status: string }> {
switch (job.name) {
case 'create-backup':
return processCreateBackupJob(job);
case 'restore-backup':
return processRestoreBackupJob(job);
case 'apply-world-upload':
return processApplyWorldUploadJob(job);
case 'enforce-retention':
return processRetentionJob(job);
default:
logger.warn({ jobName: job.name }, 'Unknown backup job');
return { status: 'ignored' };
}
}

View File

@@ -4,6 +4,7 @@ import { validateConfig } from '@hexahost/config';
import { prisma } from '@hexahost/database';
import { processNotificationJob } from './handlers/notifications';
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
import { processLifecycleJob } from './handlers/server-lifecycle';
import { processProvisionServerJob } from './handlers/server-provisioning';
import { startHealthServer } from './health';
@@ -11,6 +12,7 @@ import { logger } from './logger';
import { closeRedisConnections } from './redis';
import {
QUEUE_NOTIFICATIONS,
QUEUE_SERVER_BACKUPS,
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
WORKER_QUEUES,
@@ -37,6 +39,10 @@ function createQueueWorker(
return processProvisionServerJob(job);
}
if (queueName === QUEUE_SERVER_BACKUPS) {
return processBackupJob(job);
}
if (queueName === QUEUE_SERVER_LIFECYCLE) {
return processLifecycleJob(job);
}
@@ -75,9 +81,17 @@ async function bootstrap(): Promise<void> {
logger.info({ queues: WORKER_QUEUES }, 'Worker started');
const scheduleTimer = setInterval(() => {
void processScheduledBackupsTick().catch((error: Error) => {
logger.error({ err: error }, 'Scheduled backup tick failed');
});
}, 60_000);
const shutdown = async (signal: string): Promise<void> => {
logger.info({ signal }, 'Shutting down worker');
clearInterval(scheduleTimer);
await Promise.all(workers.map((worker) => worker.close()));
await closeRedisConnections();
await prisma.$disconnect();

View File

@@ -1,11 +1,33 @@
export const QUEUE_SERVER_BACKUPS = 'server-backups' as const;
export const QUEUE_SERVER_LIFECYCLE = 'server-lifecycle' as const;
export const QUEUE_SERVER_PROVISIONING = 'server-provisioning' as const;
export const QUEUE_NOTIFICATIONS = 'notifications' as const;
import { Queue } from 'bullmq';
import { validateConfig } from '@hexahost/config';
export const WORKER_QUEUES = [
QUEUE_SERVER_BACKUPS,
QUEUE_SERVER_LIFECYCLE,
QUEUE_SERVER_PROVISIONING,
QUEUE_NOTIFICATIONS,
] as const;
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
let backupsQueue: Queue | null = null;
export function getBackupsQueue(): Queue {
if (!backupsQueue) {
const config = validateConfig();
backupsQueue = new Queue(QUEUE_SERVER_BACKUPS, {
connection: {
url: config.REDIS_URL,
maxRetriesPerRequest: null,
},
});
}
return backupsQueue;
}

View File

@@ -21,6 +21,7 @@ export interface NodeCommandMessage {
export interface NodeResponseMessage {
success: boolean;
result?: unknown;
resultCode?: string;
errorCode?: string;
errorMessage?: string;

File diff suppressed because one or more lines are too long

View File

@@ -2,92 +2,76 @@
Last updated: 2026-06-26
## Current phase: Phase 3Console, files, settings ✅
## Current phase: Phase 4Worlds & backups ✅
### Phase 3 completed
### Phase 4 completed
| Area | Status | Notes |
|------|--------|-------|
| Go agent v0.3 | Done | Log stream, RCON commands, safe file I/O |
| `@hexahost/contracts` phase 3 | Done | console, files, properties, players |
| API NodeBridge | Done | Sync request/response over Redis |
| API Console | Done | Token + WS `/api/v1/servers/:id/console/ws` |
| API Files | Done | List, read, write with path validation |
| API Properties | Done | GET/PATCH `server.properties` |
| API Players | Done | Online list + whitelist |
| API Restart | Done | `POST /servers/:id/restart?skip=true` |
| Audit events | Done | console.command, file.write, properties.update |
| Web server tabs | Done | Console, files, properties, players |
| Unit tests | Done | Properties parser, path validation, route contracts |
| Prisma backup models | Done | ServerBackup, BackupRestore, BackupSchedule, WorldUpload |
| `@hexahost/storage` | Done | S3/MinIO presigned URLs, object keys |
| `@hexahost/contracts` phase 4 | Done | backups, worlds schemas |
| Go agent v0.4 | Done | Archive, validate, RCON prepare/release, S3 transfer |
| Worker backup queue | Done | create/restore/upload, retention, schedule tick |
| API BackupsModule | Done | CRUD, restore, schedule, download URL |
| API WorldsModule | Done | World info, upload, download via backup |
| Web worlds/backups tabs | Done | Upload, list, backup management |
| State machine | Done | BACKING_UP, RESTORING |
### Abnahmekriterium Phase 3
### Abnahmekriterium Phase 4
- [x] Live console WebSocket (logs + commands when RUNNING)
- [x] File manager (list/read/write, path sandbox)
- [x] `server.properties` editor with restart option
- [x] Player list + whitelist view
- [x] Server management fully via web panel (no direct host access required)
- [ ] Manual E2E validation (requires Docker + agent running locally)
- [x] World backup to S3 (MinIO in dev)
- [x] World download (presigned URL)
- [x] World upload and replace (server stopped)
- [x] Backup restore with safety backup
- [x] Retention enforcement
- [x] Scheduled backups (interval-based)
- [ ] Manual E2E validation (requires Docker + agent + MinIO)
### Local vertical slice
```bash
pnpm dev:infra
pnpm db:migrate # or db:push
pnpm dev:infra # includes MinIO on :9000
pnpm db:migrate
pnpm db:seed
pnpm dev
# Terminal 2 — Node Agent (requires Go + Docker)
cd apps/node-agent
set NODE_ID=00000000-0000-4000-8000-000000000001
set NODE_TOKEN=local-dev-enrollment-token-change-me-32chars
set NODE_AGENT_PUBLIC_URL=ws://localhost:3001/api/v1/nodes/ws
make run
# Node Agent (Go + Docker)
cd apps/node-agent && make run
```
1. Create and start a server under `/servers/new`
2. Open **Console** tab — live logs, send commands via RCON
3. Open **Files** — browse server directory, edit files
4. Open **Properties** — change `server.properties`, optional restart
5. Open **Players** — online players and whitelist
1. Stop server → **Welten**`.tar.gz` hochladen
2. **Backups** → manuelles Backup erstellen → Download
3. Backup wiederherstellen (Server gestoppt)
4. Geplante Backups aktivieren
### Known limitations
- Console only when server status is `RUNNING`; files readable when `RUNNING` or `STOPPED`.
- RCON via `rcon-cli` inside Minecraft container (itzg image).
- File writes limited to server data directory; no upload/download archives yet (Phase 4).
- Player management read-only (whitelist view); add/remove deferred.
- Single-node only; no scheduler fairness yet.
- Upload expects gzip-compressed tar (`.tar.gz`) with world folder or `level.dat` at root.
- Restore/download require agent on node; worker orchestrates S3 transfer.
- Scheduled backups use simple `intervalHours` (no cron expressions yet).
- No server-side encryption of backups beyond MinIO/S3 transport (deferred).
---
## Phase 3 — Console, files, settings ✅
Live console, file manager, `server.properties`, player lists, audit.
---
## Phase 2 — Single-Node Vertical Slice ✅
| Area | Status | Notes |
|------|--------|-------|
| Prisma lifecycle models | Done | Allocations, server fields, node enrollment |
| `@hexahost/contracts` servers | Done | Zod schemas for create/list/action |
| API ServersModule | Done | CRUD, start/stop, BullMQ jobs |
| API NodesModule | Done | WebSocket `/api/v1/nodes/ws`, Redis command bridge |
| Worker provisioning | Done | Port allocation, Redis commands, response wait |
| Go node-agent v0.2 | Done | WebSocket, Docker (`itzg/minecraft-server:java21`) |
| Web server UI | Done | Create, list, detail, start/stop |
| Migration | Done | `20260626140000_phase2_game_servers` |
Provisioning, start/stop, node agent, web panel.
---
## Phase 1 — Authentication ✅
Session cookies, register/login/logout, email verify, password reset, TOTP 2FA, recovery codes, audit baseline. See `docs/adr/0002-session-authentication.md`.
---
## Phase 0 — Monorepo foundation ✅
pnpm + Turborepo monorepo, PostgreSQL/Redis/MinIO/Mailpit compose, CI, ADRs.
### Next phase: Phase 5 — Software & add-ons
### Next phase: Phase 4 — Worlds & backups
- S3-compatible storage (MinIO)
- World upload/download
- Backup jobs and restore
- Software catalog, Modrinth, mods/plugins/modpacks

View File

@@ -0,0 +1,102 @@
import { z } from 'zod';
export const backupStatusSchema = z.enum([
'PENDING',
'RUNNING',
'AVAILABLE',
'FAILED',
'DELETED',
]);
export const backupTypeSchema = z.enum([
'MANUAL',
'SCHEDULED',
'PRE_RESTORE',
'PRE_WORLD_REPLACE',
]);
export const backupRestoreStatusSchema = z.enum([
'PENDING',
'RUNNING',
'COMPLETED',
'FAILED',
]);
export const backupResponseSchema = z.object({
id: z.string().uuid(),
serverId: z.string().uuid(),
type: backupTypeSchema,
status: backupStatusSchema,
s3Key: z.string().nullable(),
sizeBytes: z.string().nullable(),
sha256: z.string().nullable(),
label: z.string().nullable(),
failureReason: z.string().nullable(),
createdAt: z.string().datetime(),
completedAt: z.string().datetime().nullable(),
expiresAt: z.string().datetime().nullable(),
});
export const backupListResponseSchema = z.object({
backups: z.array(backupResponseSchema),
});
export const createBackupRequestSchema = z.object({
label: z.string().max(128).optional(),
});
export const backupActionResponseSchema = z.object({
backup: backupResponseSchema,
jobId: z.string().optional(),
});
export const backupDownloadUrlResponseSchema = z.object({
downloadUrl: z.string().url(),
expiresAt: z.string().datetime(),
});
export const restoreBackupRequestSchema = z.object({
confirm: z.literal(true, {
errorMap: () => ({ message: 'Restore must be explicitly confirmed' }),
}),
});
export const backupRestoreResponseSchema = z.object({
restore: z.object({
id: z.string().uuid(),
serverId: z.string().uuid(),
backupId: z.string().uuid(),
safetyBackupId: z.string().uuid().nullable(),
status: backupRestoreStatusSchema,
failureReason: z.string().nullable(),
createdAt: z.string().datetime(),
completedAt: z.string().datetime().nullable(),
}),
jobId: z.string().optional(),
});
export const backupScheduleSchema = z.object({
enabled: z.boolean(),
intervalHours: z.number().int().min(1).max(168),
retentionCount: z.number().int().min(1).max(50),
nextRunAt: z.string().datetime().nullable(),
lastRunAt: z.string().datetime().nullable(),
});
export const updateBackupScheduleSchema = z.object({
enabled: z.boolean().optional(),
intervalHours: z.number().int().min(1).max(168).optional(),
retentionCount: z.number().int().min(1).max(50).optional(),
});
export type BackupStatus = z.infer<typeof backupStatusSchema>;
export type BackupType = z.infer<typeof backupTypeSchema>;
export type BackupResponse = z.infer<typeof backupResponseSchema>;
export type BackupListResponse = z.infer<typeof backupListResponseSchema>;
export type CreateBackupRequest = z.infer<typeof createBackupRequestSchema>;
export type BackupActionResponse = z.infer<typeof backupActionResponseSchema>;
export type BackupDownloadUrlResponse = z.infer<typeof backupDownloadUrlResponseSchema>;
export type RestoreBackupRequest = z.infer<typeof restoreBackupRequestSchema>;
export type BackupRestoreResponse = z.infer<typeof backupRestoreResponseSchema>;
export type BackupSchedule = z.infer<typeof backupScheduleSchema>;
export type UpdateBackupSchedule = z.infer<typeof updateBackupScheduleSchema>;

View File

@@ -113,3 +113,44 @@ export {
type OperatorEntry,
type PlayerListResponse,
} from './players';
export {
backupStatusSchema,
backupTypeSchema,
backupRestoreStatusSchema,
backupResponseSchema,
backupListResponseSchema,
createBackupRequestSchema,
backupActionResponseSchema,
backupDownloadUrlResponseSchema,
restoreBackupRequestSchema,
backupRestoreResponseSchema,
backupScheduleSchema,
updateBackupScheduleSchema,
type BackupStatus,
type BackupType,
type BackupResponse,
type BackupListResponse,
type CreateBackupRequest,
type BackupActionResponse,
type BackupDownloadUrlResponse,
type RestoreBackupRequest,
type BackupRestoreResponse,
type BackupSchedule,
type UpdateBackupSchedule,
} from './backups';
export {
worldSummarySchema,
worldInfoResponseSchema,
initiateWorldUploadSchema,
worldUploadInitResponseSchema,
worldUploadResponseSchema,
worldDownloadUrlResponseSchema,
type WorldSummary,
type WorldInfoResponse,
type InitiateWorldUpload,
type WorldUploadInitResponse,
type WorldUploadResponse,
type WorldDownloadUrlResponse,
} from './worlds';

View File

@@ -8,6 +8,8 @@ export const gameServerStatusSchema = z.enum([
'STARTING',
'RUNNING',
'STOPPING',
'BACKING_UP',
'RESTORING',
'ERROR',
'DELETING',
'DELETED',
@@ -53,6 +55,7 @@ export const serverResponseSchema = z.object({
hostPort: z.number().int().nullable(),
containerId: z.string().nullable(),
dataPath: z.string().nullable(),
activeWorldName: z.string(),
ramMb: z.number().int(),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),

View File

@@ -0,0 +1,59 @@
import { z } from 'zod';
export const worldSummarySchema = z.object({
name: z.string(),
path: z.string(),
sizeBytes: z.string(),
hasLevelDat: z.boolean(),
});
export const worldInfoResponseSchema = z.object({
activeWorldName: z.string(),
worlds: z.array(worldSummarySchema),
});
export const initiateWorldUploadSchema = z.object({
worldName: z
.string()
.min(1)
.max(64)
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid world name'),
});
export const worldUploadInitResponseSchema = z.object({
uploadId: z.string().uuid(),
uploadUrl: z.string().url(),
expiresAt: z.string().datetime(),
s3Key: z.string(),
});
export const worldUploadResponseSchema = z.object({
id: z.string().uuid(),
serverId: z.string().uuid(),
status: z.enum([
'PENDING',
'UPLOADING',
'VALIDATING',
'READY',
'COMPLETED',
'FAILED',
]),
worldName: z.string(),
sizeBytes: z.string().nullable(),
failureReason: z.string().nullable(),
createdAt: z.string().datetime(),
completedAt: z.string().datetime().nullable(),
});
export const worldDownloadUrlResponseSchema = z.object({
downloadUrl: z.string().url(),
expiresAt: z.string().datetime(),
backupId: z.string().uuid(),
});
export type WorldSummary = z.infer<typeof worldSummarySchema>;
export type WorldInfoResponse = z.infer<typeof worldInfoResponseSchema>;
export type InitiateWorldUpload = z.infer<typeof initiateWorldUploadSchema>;
export type WorldUploadInitResponse = z.infer<typeof worldUploadInitResponseSchema>;
export type WorldUploadResponse = z.infer<typeof worldUploadResponseSchema>;
export type WorldDownloadUrlResponse = z.infer<typeof worldDownloadUrlResponseSchema>;

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,99 @@
-- Phase 4: Worlds & Backups
-- AlterEnum
ALTER TYPE "GameServerStatus" ADD VALUE 'BACKING_UP';
ALTER TYPE "GameServerStatus" ADD VALUE 'RESTORING';
-- CreateEnum
CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'RUNNING', 'AVAILABLE', 'FAILED', 'DELETED');
CREATE TYPE "BackupType" AS ENUM ('MANUAL', 'SCHEDULED', 'PRE_RESTORE', 'PRE_WORLD_REPLACE');
CREATE TYPE "BackupRestoreStatus" AS ENUM ('PENDING', 'RUNNING', 'COMPLETED', 'FAILED');
CREATE TYPE "WorldUploadStatus" AS ENUM ('PENDING', 'UPLOADING', 'VALIDATING', 'READY', 'COMPLETED', 'FAILED');
-- AlterTable
ALTER TABLE "game_servers" ADD COLUMN "activeWorldName" TEXT NOT NULL DEFAULT 'world';
-- CreateTable
CREATE TABLE "server_backups" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"type" "BackupType" NOT NULL DEFAULT 'MANUAL',
"status" "BackupStatus" NOT NULL DEFAULT 'PENDING',
"s3Key" TEXT,
"sizeBytes" BIGINT,
"sha256" TEXT,
"label" TEXT,
"failureReason" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMPTZ(3),
"expiresAt" TIMESTAMPTZ(3),
CONSTRAINT "server_backups_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "backup_restores" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"backupId" TEXT NOT NULL,
"safetyBackupId" TEXT,
"status" "BackupRestoreStatus" NOT NULL DEFAULT 'PENDING',
"failureReason" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMPTZ(3),
CONSTRAINT "backup_restores_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "backup_schedules" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"intervalHours" INTEGER NOT NULL DEFAULT 24,
"retentionCount" INTEGER NOT NULL DEFAULT 5,
"nextRunAt" TIMESTAMPTZ(3),
"lastRunAt" TIMESTAMPTZ(3),
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "backup_schedules_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "world_uploads" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"status" "WorldUploadStatus" NOT NULL DEFAULT 'PENDING',
"s3Key" TEXT NOT NULL,
"worldName" TEXT NOT NULL DEFAULT 'world',
"sha256" TEXT,
"sizeBytes" BIGINT,
"failureReason" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"completedAt" TIMESTAMPTZ(3),
CONSTRAINT "world_uploads_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "server_backups_serverId_idx" ON "server_backups"("serverId");
CREATE INDEX "server_backups_status_idx" ON "server_backups"("status");
CREATE INDEX "server_backups_createdAt_idx" ON "server_backups"("createdAt");
CREATE INDEX "backup_restores_serverId_idx" ON "backup_restores"("serverId");
CREATE INDEX "backup_restores_backupId_idx" ON "backup_restores"("backupId");
CREATE INDEX "backup_restores_status_idx" ON "backup_restores"("status");
CREATE UNIQUE INDEX "backup_schedules_serverId_key" ON "backup_schedules"("serverId");
CREATE INDEX "world_uploads_serverId_idx" ON "world_uploads"("serverId");
CREATE INDEX "world_uploads_status_idx" ON "world_uploads"("status");
-- AddForeignKey
ALTER TABLE "server_backups" ADD CONSTRAINT "server_backups_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_backupId_fkey" FOREIGN KEY ("backupId") REFERENCES "server_backups"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "backup_restores" ADD CONSTRAINT "backup_restores_safetyBackupId_fkey" FOREIGN KEY ("safetyBackupId") REFERENCES "server_backups"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "backup_schedules" ADD CONSTRAINT "backup_schedules_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "world_uploads" ADD CONSTRAINT "world_uploads_serverId_fkey" FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -37,6 +37,8 @@ enum GameServerStatus {
STARTING
RUNNING
STOPPING
BACKING_UP
RESTORING
ERROR
DELETING
DELETED
@@ -63,6 +65,37 @@ enum JobStatus {
CANCELLED
}
enum BackupStatus {
PENDING
RUNNING
AVAILABLE
FAILED
DELETED
}
enum BackupType {
MANUAL
SCHEDULED
PRE_RESTORE
PRE_WORLD_REPLACE
}
enum BackupRestoreStatus {
PENDING
RUNNING
COMPLETED
FAILED
}
enum WorldUploadStatus {
PENDING
UPLOADING
VALIDATING
READY
COMPLETED
FAILED
}
model User {
id String @id @default(uuid())
email String @unique
@@ -219,6 +252,7 @@ model GameServer {
containerId String?
dataPath String?
ramMb Int @default(1024)
activeWorldName String @default("world")
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -227,6 +261,10 @@ model GameServer {
node GameNode? @relation(fields: [nodeId], references: [id])
stateTransitions GameServerStateTransition[]
allocation GameServerAllocation?
backups ServerBackup[]
backupRestores BackupRestore[]
backupSchedule BackupSchedule?
worldUploads WorldUpload[]
@@index([userId])
@@index([nodeId])
@@ -385,3 +423,82 @@ model JobRecord {
@@index([createdAt])
@@map("job_records")
}
model ServerBackup {
id String @id @default(uuid())
serverId String
type BackupType @default(MANUAL)
status BackupStatus @default(PENDING)
s3Key String?
sizeBytes BigInt?
sha256 String?
label String?
failureReason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
completedAt DateTime? @db.Timestamptz(3)
expiresAt DateTime? @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
restores BackupRestore[] @relation("RestoredFromBackup")
safetyForRestores BackupRestore[] @relation("SafetyBackup")
@@index([serverId])
@@index([status])
@@index([createdAt])
@@map("server_backups")
}
model BackupRestore {
id String @id @default(uuid())
serverId String
backupId String
safetyBackupId String?
status BackupRestoreStatus @default(PENDING)
failureReason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
completedAt DateTime? @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
backup ServerBackup @relation("RestoredFromBackup", fields: [backupId], references: [id])
safetyBackup ServerBackup? @relation("SafetyBackup", fields: [safetyBackupId], references: [id])
@@index([serverId])
@@index([backupId])
@@index([status])
@@map("backup_restores")
}
model BackupSchedule {
id String @id @default(uuid())
serverId String @unique
enabled Boolean @default(true)
intervalHours Int @default(24)
retentionCount Int @default(5)
nextRunAt DateTime? @db.Timestamptz(3)
lastRunAt DateTime? @db.Timestamptz(3)
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@map("backup_schedules")
}
model WorldUpload {
id String @id @default(uuid())
serverId String
status WorldUploadStatus @default(PENDING)
s3Key String
worldName String @default("world")
sha256 String?
sizeBytes BigInt?
failureReason String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
completedAt DateTime? @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@index([serverId])
@@index([status])
@@map("world_uploads")
}

View File

@@ -10,7 +10,12 @@ export type {
PlatformRole,
UserPlatformRole,
GameServer,
GameServerAllocation,
GameServerStateTransition,
ServerBackup,
BackupRestore,
BackupSchedule,
WorldUpload,
GameNode,
GameNodeHeartbeat,
Plan,

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,23 @@
{
"name": "@hexahost/storage",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.750.0",
"@aws-sdk/s3-request-presigner": "^3.750.0",
"zod": "^3.24.1"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,90 @@
import {
DeleteObjectCommand,
HeadObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import { GetObjectCommand, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
import type { StorageConfig } from './config';
export function createS3Client(config: StorageConfig): S3Client {
return new S3Client({
region: config.S3_REGION,
endpoint: config.S3_ENDPOINT,
forcePathStyle: config.S3_FORCE_PATH_STYLE,
credentials: {
accessKeyId: config.S3_ACCESS_KEY,
secretAccessKey: config.S3_SECRET_KEY,
},
});
}
export function backupObjectKey(serverId: string, backupId: string): string {
return `servers/${serverId}/backups/${backupId}.tar.gz`;
}
export function worldUploadObjectKey(serverId: string, uploadId: string): string {
return `servers/${serverId}/uploads/${uploadId}/world.tar.gz`;
}
export async function createPresignedPutUrl(
client: S3Client,
bucket: string,
key: string,
expiresInSeconds: number,
contentType = 'application/gzip',
): Promise<string> {
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
ContentType: contentType,
});
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
}
export async function createPresignedGetUrl(
client: S3Client,
bucket: string,
key: string,
expiresInSeconds: number,
): Promise<string> {
const command = new GetObjectCommand({
Bucket: bucket,
Key: key,
});
return getSignedUrl(client, command, { expiresIn: expiresInSeconds });
}
export async function deleteStoredObject(
client: S3Client,
bucket: string,
key: string,
): Promise<void> {
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}),
);
}
export async function headStoredObject(
client: S3Client,
bucket: string,
key: string,
): Promise<{ sizeBytes: number; etag?: string }> {
const response = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
}),
);
return {
sizeBytes: Number(response.ContentLength ?? 0),
etag: response.ETag,
};
}

View File

@@ -0,0 +1,28 @@
import { z } from 'zod';
export const storageConfigSchema = z.object({
S3_ENDPOINT: z.string().url(),
S3_REGION: z.string().min(1).default('us-east-1'),
S3_BUCKET: z.string().min(1),
S3_ACCESS_KEY: z.string().min(1),
S3_SECRET_KEY: z.string().min(1),
S3_FORCE_PATH_STYLE: z
.string()
.optional()
.transform((value) => value === 'true' || value === '1'),
});
export type StorageConfig = z.infer<typeof storageConfigSchema>;
export function loadStorageConfig(
env: Record<string, string | undefined> = process.env,
): StorageConfig {
return storageConfigSchema.parse({
S3_ENDPOINT: env['S3_ENDPOINT'],
S3_REGION: env['S3_REGION'],
S3_BUCKET: env['S3_BUCKET'],
S3_ACCESS_KEY: env['S3_ACCESS_KEY'],
S3_SECRET_KEY: env['S3_SECRET_KEY'],
S3_FORCE_PATH_STYLE: env['S3_FORCE_PATH_STYLE'],
});
}

View File

@@ -0,0 +1,15 @@
export {
storageConfigSchema,
loadStorageConfig,
type StorageConfig,
} from './config';
export {
createS3Client,
backupObjectKey,
worldUploadObjectKey,
createPresignedPutUrl,
createPresignedGetUrl,
deleteStoredObject,
headStoredObject,
} from './client';

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest';
import { backupObjectKey, worldUploadObjectKey } from './client';
describe('storage object keys', () => {
it('builds backup keys', () => {
expect(backupObjectKey('srv-1', 'bak-1')).toBe(
'servers/srv-1/backups/bak-1.tar.gz',
);
});
it('builds world upload keys', () => {
expect(worldUploadObjectKey('srv-1', 'up-1')).toBe(
'servers/srv-1/uploads/up-1/world.tar.gz',
);
});
});

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

Some files were not shown because too many files have changed in this diff Show More