Phase5
This commit is contained in:
@@ -9,13 +9,14 @@
|
||||
"start:prod": "node dist/main.js",
|
||||
"lint": "node -e \"process.exit(0)\"",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "tsc && node --test test/**/*.test.js"
|
||||
"test": "tsc -p tsconfig.build.json && node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/static": "^8.0.4",
|
||||
"@fastify/websocket": "^11.0.2",
|
||||
"@hexahost/auth": "workspace:*",
|
||||
"@hexahost/catalog": "workspace:*",
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/contracts": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
|
||||
@@ -4,6 +4,7 @@ import { RequestIdMiddleware } from './common/middleware/request-id.middleware';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { NodesModule } from './nodes/nodes.module';
|
||||
import { ServersModule } from './servers/servers.module';
|
||||
@@ -40,6 +41,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
AppConfigModule,
|
||||
HealthModule,
|
||||
AuthModule,
|
||||
CatalogModule,
|
||||
ServersModule,
|
||||
NodesModule,
|
||||
AdminModule,
|
||||
|
||||
67
apps/api/src/catalog/catalog.controller.ts
Normal file
67
apps/api/src/catalog/catalog.controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { BadRequestException, Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { catalogSearchQuerySchema } from '@hexahost/contracts';
|
||||
|
||||
import { SessionGuard } from '../auth/guards/session.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@ApiTags('catalog')
|
||||
@Controller('catalog')
|
||||
@UseGuards(SessionGuard)
|
||||
export class CatalogController {
|
||||
constructor(
|
||||
private readonly catalogService: CatalogService,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
@Get('projects')
|
||||
@ApiOperation({ summary: 'Search Modrinth projects' })
|
||||
search(@Query() query: Record<string, string | undefined>) {
|
||||
const input = catalogSearchQuerySchema.parse({
|
||||
q: query['q'],
|
||||
gameVersion: query['gameVersion'],
|
||||
softwareFamily: query['softwareFamily'],
|
||||
limit: query['limit'],
|
||||
});
|
||||
|
||||
return this.catalogService.searchProjects({
|
||||
query: input.q,
|
||||
gameVersion: input.gameVersion,
|
||||
softwareFamily: input.softwareFamily,
|
||||
limit: input.limit,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('projects/:projectId/versions')
|
||||
@ApiOperation({ summary: 'List Modrinth project versions' })
|
||||
async versions(
|
||||
@Param('projectId') projectId: string,
|
||||
@Query('serverId') serverId?: string,
|
||||
@Query('gameVersion') gameVersion?: string,
|
||||
@Query('softwareFamily') softwareFamily?: string,
|
||||
) {
|
||||
let resolvedGameVersion = gameVersion;
|
||||
let resolvedSoftwareFamily = softwareFamily;
|
||||
|
||||
if (serverId) {
|
||||
const server = await this.prisma.gameServer.findUniqueOrThrow({
|
||||
where: { id: serverId },
|
||||
});
|
||||
resolvedGameVersion = server.minecraftVersion;
|
||||
resolvedSoftwareFamily = server.softwareFamily;
|
||||
}
|
||||
|
||||
if (!resolvedGameVersion || !resolvedSoftwareFamily) {
|
||||
throw new BadRequestException('gameVersion and softwareFamily are required');
|
||||
}
|
||||
|
||||
return this.catalogService.listProjectVersions(
|
||||
projectId,
|
||||
resolvedGameVersion,
|
||||
resolvedSoftwareFamily as 'PAPER' | 'FABRIC' | 'PURPUR' | 'FORGE' | 'NEOFORGE' | 'QUILT',
|
||||
);
|
||||
}
|
||||
}
|
||||
15
apps/api/src/catalog/catalog.module.ts
Normal file
15
apps/api/src/catalog/catalog.module.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
import { CatalogController } from './catalog.controller';
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [CatalogController],
|
||||
providers: [CatalogService],
|
||||
exports: [CatalogService],
|
||||
})
|
||||
export class CatalogModule {}
|
||||
81
apps/api/src/catalog/catalog.service.ts
Normal file
81
apps/api/src/catalog/catalog.service.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
|
||||
import type {
|
||||
CatalogSearchResponse,
|
||||
CatalogVersionsResponse,
|
||||
} from '@hexahost/contracts';
|
||||
import {
|
||||
getModrinthProjectVersions,
|
||||
isVersionCompatible,
|
||||
resolveModrinthLoader,
|
||||
resolveProjectType,
|
||||
searchModrinthProjects,
|
||||
type SoftwareFamily,
|
||||
} from '@hexahost/catalog';
|
||||
|
||||
@Injectable()
|
||||
export class CatalogService {
|
||||
async searchProjects(input: {
|
||||
query: string;
|
||||
gameVersion: string;
|
||||
softwareFamily: SoftwareFamily;
|
||||
limit?: number;
|
||||
}): Promise<CatalogSearchResponse> {
|
||||
const projectType = resolveProjectType(input.softwareFamily);
|
||||
const loader = resolveModrinthLoader(input.softwareFamily);
|
||||
|
||||
if (!projectType || !loader) {
|
||||
throw new BadRequestException(
|
||||
'This server software does not support catalog add-ons',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await searchModrinthProjects({
|
||||
query: input.query,
|
||||
projectType,
|
||||
gameVersion: input.gameVersion,
|
||||
loader,
|
||||
limit: input.limit,
|
||||
});
|
||||
|
||||
return {
|
||||
projects: result.hits.map((project) => ({
|
||||
id: project.project_id,
|
||||
slug: project.slug,
|
||||
name: project.title,
|
||||
description: project.description,
|
||||
projectType: project.project_type,
|
||||
downloads: project.downloads,
|
||||
iconUrl: project.icon_url ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listProjectVersions(
|
||||
projectId: string,
|
||||
gameVersion: string,
|
||||
softwareFamily: SoftwareFamily,
|
||||
): Promise<CatalogVersionsResponse> {
|
||||
const loader = resolveModrinthLoader(softwareFamily);
|
||||
if (!loader) {
|
||||
throw new BadRequestException(
|
||||
'This server software does not support catalog add-ons',
|
||||
);
|
||||
}
|
||||
|
||||
const versions = await getModrinthProjectVersions(projectId);
|
||||
|
||||
return {
|
||||
versions: versions.map((version) => ({
|
||||
id: version.id,
|
||||
projectId: version.project_id,
|
||||
name: version.name,
|
||||
versionNumber: version.version_number,
|
||||
gameVersions: version.game_versions,
|
||||
loaders: version.loaders,
|
||||
compatible: isVersionCompatible(version, gameVersion, loader),
|
||||
publishedAt: version.date_published,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -327,7 +327,9 @@ export class NodesService {
|
||||
normalized === 'world.archive' ||
|
||||
normalized === 'world.replace' ||
|
||||
normalized === 'storage.upload' ||
|
||||
normalized === 'storage.download'
|
||||
normalized === 'storage.download' ||
|
||||
normalized === 'addon.install' ||
|
||||
normalized === 'addon.remove'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
56
apps/api/src/servers/addons/addons.controller.ts
Normal file
56
apps/api/src/servers/addons/addons.controller.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import type { AddonListResponse, InstallAddonResponse } from '@hexahost/contracts';
|
||||
import { installAddonRequestSchema } from '@hexahost/contracts';
|
||||
|
||||
import { CurrentUser } from '../../auth/decorators/current-user.decorator';
|
||||
import { SessionGuard } from '../../auth/guards/session.guard';
|
||||
|
||||
import { AddonsService } from './addons.service';
|
||||
|
||||
@ApiTags('servers')
|
||||
@Controller('servers/:serverId/addons')
|
||||
@UseGuards(SessionGuard)
|
||||
export class AddonsController {
|
||||
constructor(private readonly addonsService: AddonsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List installed add-ons' })
|
||||
list(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
): Promise<AddonListResponse> {
|
||||
return this.addonsService.listAddons(user.id, serverId);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Install an add-on from the catalog' })
|
||||
install(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
const input = installAddonRequestSchema.parse(body);
|
||||
return this.addonsService.installAddon(user.id, serverId, input);
|
||||
}
|
||||
|
||||
@Delete(':addonId')
|
||||
@ApiOperation({ summary: 'Remove an installed add-on' })
|
||||
remove(
|
||||
@CurrentUser() user: { id: string },
|
||||
@Param('serverId', ParseUUIDPipe) serverId: string,
|
||||
@Param('addonId', ParseUUIDPipe) addonId: string,
|
||||
): Promise<InstallAddonResponse> {
|
||||
return this.addonsService.removeAddon(user.id, serverId, addonId);
|
||||
}
|
||||
}
|
||||
34
apps/api/src/servers/addons/addons.module.ts
Normal file
34
apps/api/src/servers/addons/addons.module.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import { getConfig } from '@hexahost/config';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { ServerStateService } from '../server-state.service';
|
||||
|
||||
import { AddonsController } from './addons.controller';
|
||||
import { AddonsService, SERVER_ADDONS_QUEUE } from './addons.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PrismaModule],
|
||||
controllers: [AddonsController],
|
||||
providers: [
|
||||
AddonsService,
|
||||
ServerStateService,
|
||||
{
|
||||
provide: SERVER_ADDONS_QUEUE,
|
||||
useFactory: () => {
|
||||
const config = getConfig();
|
||||
return new Queue('server-addons', {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
exports: [AddonsService],
|
||||
})
|
||||
export class AddonsModule {}
|
||||
229
apps/api/src/servers/addons/addons.service.ts
Normal file
229
apps/api/src/servers/addons/addons.service.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { Queue } from 'bullmq';
|
||||
|
||||
import type {
|
||||
AddonListResponse,
|
||||
InstallAddonRequest,
|
||||
InstallAddonResponse,
|
||||
InstalledAddon,
|
||||
} from '@hexahost/contracts';
|
||||
import type { InstalledAddon as InstalledAddonRecord } from '@hexahost/database';
|
||||
import {
|
||||
getModrinthProject,
|
||||
getModrinthVersion,
|
||||
isVersionCompatible,
|
||||
pickPrimaryFile,
|
||||
resolveInstallDirectory,
|
||||
resolveModrinthLoader,
|
||||
resolveProjectType,
|
||||
} from '@hexahost/catalog';
|
||||
|
||||
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_ADDONS_QUEUE = Symbol('SERVER_ADDONS_QUEUE');
|
||||
|
||||
const STOPPED_ONLY = new Set(['STOPPED']);
|
||||
|
||||
@Injectable()
|
||||
export class AddonsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly serverState: ServerStateService,
|
||||
private readonly auditService: AuditService,
|
||||
@Inject(SERVER_ADDONS_QUEUE) private readonly addonsQueue: Queue,
|
||||
) {}
|
||||
|
||||
async listAddons(userId: string, serverId: string): Promise<AddonListResponse> {
|
||||
await findOwnedServer(this.prisma, userId, serverId);
|
||||
|
||||
const addons = await this.prisma.installedAddon.findMany({
|
||||
where: {
|
||||
serverId,
|
||||
status: { not: 'REMOVED' },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return { addons: addons.map((addon) => this.toResponse(addon)) };
|
||||
}
|
||||
|
||||
async installAddon(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
input: InstallAddonRequest,
|
||||
): Promise<InstallAddonResponse> {
|
||||
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 (!STOPPED_ONLY.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
'Addons can only be installed while the server is STOPPED',
|
||||
);
|
||||
}
|
||||
|
||||
const projectType = resolveProjectType(server.softwareFamily);
|
||||
const loader = resolveModrinthLoader(server.softwareFamily);
|
||||
|
||||
if (!projectType || !loader) {
|
||||
throw new BadRequestException(
|
||||
'This server software does not support add-on installation',
|
||||
);
|
||||
}
|
||||
|
||||
const version = await getModrinthVersion(input.versionId);
|
||||
if (version.project_id !== input.projectId) {
|
||||
throw new BadRequestException('Version does not belong to the selected project');
|
||||
}
|
||||
|
||||
if (!isVersionCompatible(version, server.minecraftVersion, loader)) {
|
||||
throw new BadRequestException(
|
||||
'This version is not compatible with your server version and loader',
|
||||
);
|
||||
}
|
||||
|
||||
const file = pickPrimaryFile(version);
|
||||
if (!file) {
|
||||
throw new BadRequestException('No installable file found for this version');
|
||||
}
|
||||
|
||||
const project = await getModrinthProject(input.projectId);
|
||||
const projectName = project.title;
|
||||
const projectSlug = project.slug;
|
||||
|
||||
const installDir = resolveInstallDirectory(projectType);
|
||||
const filePath = `${installDir}/${file.filename}`;
|
||||
|
||||
const existing = await this.prisma.installedAddon.findFirst({
|
||||
where: {
|
||||
serverId,
|
||||
projectId: input.projectId,
|
||||
status: { in: ['PENDING', 'INSTALLING', 'INSTALLED'] },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
throw new ConflictException('This project is already installed on the server');
|
||||
}
|
||||
|
||||
const addonType =
|
||||
projectType === 'plugin'
|
||||
? 'PLUGIN'
|
||||
: projectType === 'mod'
|
||||
? 'MOD'
|
||||
: projectType === 'modpack'
|
||||
? 'MODPACK'
|
||||
: 'DATAPACK';
|
||||
|
||||
const addon = await this.prisma.installedAddon.create({
|
||||
data: {
|
||||
serverId,
|
||||
projectId: input.projectId,
|
||||
projectSlug,
|
||||
projectName,
|
||||
versionId: input.versionId,
|
||||
versionNumber: version.version_number,
|
||||
addonType,
|
||||
fileName: file.filename,
|
||||
filePath,
|
||||
fileSha512: file.hashes['sha512'] ?? null,
|
||||
status: 'PENDING',
|
||||
},
|
||||
});
|
||||
|
||||
const job = await this.addonsQueue.add('install-addon', {
|
||||
addonId: addon.id,
|
||||
serverId,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.addon.install',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: {
|
||||
addonId: addon.id,
|
||||
projectId: input.projectId,
|
||||
versionId: input.versionId,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
addon: this.toResponse(addon),
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
async removeAddon(
|
||||
userId: string,
|
||||
serverId: string,
|
||||
addonId: string,
|
||||
): Promise<InstallAddonResponse> {
|
||||
const server = await findOwnedServer(this.prisma, userId, serverId);
|
||||
this.serverState.assertNotInProgress(server.status);
|
||||
|
||||
if (!STOPPED_ONLY.has(server.status)) {
|
||||
throw new BadRequestException(
|
||||
'Addons can only be removed while the server is STOPPED',
|
||||
);
|
||||
}
|
||||
|
||||
const addon = await this.prisma.installedAddon.findFirstOrThrow({
|
||||
where: { id: addonId, serverId },
|
||||
});
|
||||
|
||||
await this.prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: { status: 'REMOVING' },
|
||||
});
|
||||
|
||||
const job = await this.addonsQueue.add('remove-addon', {
|
||||
addonId,
|
||||
serverId,
|
||||
});
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'server.addon.remove',
|
||||
userId,
|
||||
entityType: 'game_server',
|
||||
entityId: serverId,
|
||||
metadata: { addonId },
|
||||
});
|
||||
|
||||
return {
|
||||
addon: this.toResponse({ ...addon, status: 'REMOVING' }),
|
||||
jobId: job.id?.toString(),
|
||||
};
|
||||
}
|
||||
|
||||
private toResponse(addon: InstalledAddonRecord): InstalledAddon {
|
||||
return {
|
||||
id: addon.id,
|
||||
serverId: addon.serverId,
|
||||
provider: addon.provider,
|
||||
projectId: addon.projectId,
|
||||
projectSlug: addon.projectSlug,
|
||||
projectName: addon.projectName,
|
||||
versionId: addon.versionId,
|
||||
versionNumber: addon.versionNumber,
|
||||
addonType: addon.addonType as InstalledAddon['addonType'],
|
||||
fileName: addon.fileName,
|
||||
filePath: addon.filePath,
|
||||
status: addon.status as InstalledAddon['status'],
|
||||
failureReason: addon.failureReason,
|
||||
installedAt: addon.installedAt?.toISOString() ?? null,
|
||||
createdAt: addon.createdAt.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { NodeBridgeModule } from '../nodes/node-bridge.module';
|
||||
import { AppConfigModule } from '../config/app-config.module';
|
||||
|
||||
import { ConsoleModule } from './console/console.module';
|
||||
import { AddonsModule } from './addons/addons.module';
|
||||
import { BackupsModule } from './backups/backups.module';
|
||||
import { FilesModule } from './files/files.module';
|
||||
import { WorldsModule } from './worlds/worlds.module';
|
||||
@@ -30,6 +31,7 @@ import { PlansService } from './plans.service';
|
||||
AppConfigModule,
|
||||
AuthModule,
|
||||
forwardRef(() => ConsoleModule),
|
||||
forwardRef(() => AddonsModule),
|
||||
forwardRef(() => BackupsModule),
|
||||
forwardRef(() => WorldsModule),
|
||||
forwardRef(() => FilesModule),
|
||||
|
||||
42
apps/api/test/phase5.test.js
Normal file
42
apps/api/test/phase5.test.js
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
describe('phase 5 api contract', () => {
|
||||
it('documents catalog and add-ons routes', () => {
|
||||
const files = [
|
||||
'catalog/catalog.controller.ts',
|
||||
'servers/addons/addons.controller.ts',
|
||||
'servers/addons/addons.service.ts',
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
const source = readFileSync(join(process.cwd(), 'src', file), 'utf8');
|
||||
assert.ok(source.length > 0, `${file} should exist`);
|
||||
}
|
||||
|
||||
const catalogSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'catalog/catalog.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(catalogSource.includes("@Get('projects')"));
|
||||
assert.ok(catalogSource.includes('projects/:projectId/versions'));
|
||||
|
||||
const addonsSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/addons/addons.controller.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(addonsSource.includes('servers/:serverId/addons'));
|
||||
assert.ok(addonsSource.includes('@Post()'));
|
||||
assert.ok(addonsSource.includes("@Delete(':addonId')"));
|
||||
|
||||
const serviceSource = readFileSync(
|
||||
join(process.cwd(), 'src', 'servers/addons/addons.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
assert.ok(serviceSource.includes('installAddon'));
|
||||
assert.ok(serviceSource.includes('removeAddon'));
|
||||
assert.ok(serviceSource.includes('AddonListResponse'));
|
||||
});
|
||||
});
|
||||
File diff suppressed because one or more lines are too long
@@ -3,7 +3,8 @@
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"baseUrl": "./"
|
||||
"baseUrl": "./",
|
||||
"incremental": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "test"]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -189,6 +189,10 @@ func mapSoftwareFamily(family string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(family)) {
|
||||
case "PAPER":
|
||||
return "PAPER"
|
||||
case "FABRIC":
|
||||
return "FABRIC"
|
||||
case "PURPUR":
|
||||
return "PURPUR"
|
||||
default:
|
||||
return "VANILLA"
|
||||
}
|
||||
|
||||
@@ -229,6 +229,30 @@ func WriteFile(dataRoot, relativePath, content string, create bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteFile removes a file relative to dataRoot.
|
||||
func DeleteFile(dataRoot, relativePath string) error {
|
||||
filePath, err := ResolvePath(dataRoot, relativePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := rejectSymlinks(dataRoot, filePath); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
info, err := os.Lstat(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return ErrIsDirectory
|
||||
}
|
||||
|
||||
return os.Remove(filePath)
|
||||
}
|
||||
|
||||
func rejectSymlinks(dataRoot, target string) error {
|
||||
root, err := filepath.Abs(filepath.Clean(dataRoot))
|
||||
if err != nil {
|
||||
|
||||
@@ -44,6 +44,8 @@ const (
|
||||
TypeServerWorldReplace MessageType = "server.world.replace"
|
||||
TypeServerStorageUpload MessageType = "server.storage.upload"
|
||||
TypeServerStorageDownload MessageType = "server.storage.download"
|
||||
TypeServerAddonInstall MessageType = "server.addon.install"
|
||||
TypeServerAddonRemove MessageType = "server.addon.remove"
|
||||
TypeServerMetricsSub MessageType = "server.metrics.subscribe"
|
||||
TypeServerLogsSubscribe MessageType = "server.logs.subscribe"
|
||||
)
|
||||
@@ -80,6 +82,8 @@ var controlToAgentTypes = map[MessageType]struct{}{
|
||||
TypeServerWorldReplace: {},
|
||||
TypeServerStorageUpload: {},
|
||||
TypeServerStorageDownload: {},
|
||||
TypeServerAddonInstall: {},
|
||||
TypeServerAddonRemove: {},
|
||||
TypeServerMetricsSub: {},
|
||||
TypeServerLogsSubscribe: {},
|
||||
}
|
||||
@@ -220,6 +224,20 @@ type ServerWorldReplacePayload struct {
|
||||
ArchivePath string `json:"archivePath"`
|
||||
}
|
||||
|
||||
// ServerAddonInstallPayload downloads and installs an addon file.
|
||||
type ServerAddonInstallPayload struct {
|
||||
OperationMeta
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
Sha512 string `json:"sha512,omitempty"`
|
||||
}
|
||||
|
||||
// ServerAddonRemovePayload removes an installed addon file.
|
||||
type ServerAddonRemovePayload struct {
|
||||
OperationMeta
|
||||
RelativePath string `json:"relativePath"`
|
||||
}
|
||||
|
||||
// FileEntry describes a file or directory in a list response.
|
||||
type FileEntry struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
@@ -570,6 +570,66 @@ func (m *Manager) HandleWorldReplace(ctx context.Context, correlationID string,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleAddonInstall downloads and installs an addon JAR.
|
||||
func (m *Manager) HandleAddonInstall(ctx context.Context, correlationID string, payload protocol.ServerAddonInstallPayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if rec.State == ServerStateRunning {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.install", "SERVER_RUNNING", "server must be stopped before installing addons")
|
||||
return
|
||||
}
|
||||
|
||||
localPath, err := files.ResolvePath(rec.DataPath, payload.RelativePath)
|
||||
if err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.install", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := transfer.DownloadFile(payload.DownloadURL, localPath); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.install", "DOWNLOAD_FAILED", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := transfer.VerifySha512File(localPath, payload.Sha512); err != nil {
|
||||
_ = os.Remove(localPath)
|
||||
m.failOperation(ctx, correlationID, meta, "addon.install", "HASH_MISMATCH", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "addon.install", map[string]string{
|
||||
"path": payload.RelativePath,
|
||||
})
|
||||
}
|
||||
|
||||
// HandleAddonRemove deletes an installed addon file.
|
||||
func (m *Manager) HandleAddonRemove(ctx context.Context, correlationID string, payload protocol.ServerAddonRemovePayload) {
|
||||
meta := payload.OperationMeta
|
||||
rec, ok := m.getServer(meta.ServerID)
|
||||
if !ok {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_NOT_FOUND", "server is not provisioned on this node")
|
||||
return
|
||||
}
|
||||
|
||||
if rec.State == ServerStateRunning {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.remove", "SERVER_RUNNING", "server must be stopped before removing addons")
|
||||
return
|
||||
}
|
||||
|
||||
if err := files.DeleteFile(rec.DataPath, payload.RelativePath); err != nil {
|
||||
m.failOperation(ctx, correlationID, meta, "addon.remove", filesErrorCode(err), err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
m.completeOperation(ctx, correlationID, meta, "addon.remove", map[string]string{
|
||||
"path": payload.RelativePath,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manager) getServer(serverID string) (*serverRecord, bool) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
34
apps/node-agent/internal/transfer/hash.go
Normal file
34
apps/node-agent/internal/transfer/hash.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"crypto/sha512"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
// VerifySha512File checks that a file matches the expected sha512 hash.
|
||||
func VerifySha512File(path, expected string) error {
|
||||
if expected == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
hasher := sha512.New()
|
||||
if _, err := io.Copy(hasher, file); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
actual := hex.EncodeToString(hasher.Sum(nil))
|
||||
if actual != expected {
|
||||
return fmt.Errorf("sha512 mismatch")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -370,6 +370,22 @@ func (c *Client) dispatch(ctx context.Context, env *protocol.Envelope) {
|
||||
}
|
||||
c.runtime.HandleWorldReplace(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerAddonInstall:
|
||||
var payload protocol.ServerAddonInstallPayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.addon.install", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleAddonInstall(ctx, env.MessageID, payload)
|
||||
|
||||
case protocol.TypeServerAddonRemove:
|
||||
var payload protocol.ServerAddonRemovePayload
|
||||
if err := json.Unmarshal(env.Payload, &payload); err != nil {
|
||||
c.log.Warn("decode server.addon.remove", "error", err)
|
||||
return
|
||||
}
|
||||
c.runtime.HandleAddonRemove(ctx, env.MessageID, payload)
|
||||
|
||||
default:
|
||||
c.log.Debug("ignored control message", "type", env.Type)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"properties": "Eigenschaften",
|
||||
"players": "Spieler",
|
||||
"worlds": "Welten",
|
||||
"backups": "Backups"
|
||||
"backups": "Backups",
|
||||
"addons": "Add-ons"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Wird geladen…",
|
||||
@@ -317,6 +318,23 @@
|
||||
"created": "Erstellt",
|
||||
"actions": "Aktionen",
|
||||
"empty": "Noch keine Backups vorhanden."
|
||||
},
|
||||
"addons": {
|
||||
"loading": "Wird geladen…",
|
||||
"unsupported": "Add-ons werden für diese Server-Software noch nicht unterstützt. Verwenden Sie Paper oder Fabric.",
|
||||
"stoppedHint": "Add-ons können nur installiert oder entfernt werden, wenn der Server gestoppt ist.",
|
||||
"searchPlaceholder": "Modrinth durchsuchen…",
|
||||
"search": "Suchen",
|
||||
"searchError": "Katalogsuche fehlgeschlagen.",
|
||||
"versionsError": "Versionen konnten nicht geladen werden.",
|
||||
"installError": "Installation fehlgeschlagen.",
|
||||
"removeError": "Entfernen fehlgeschlagen.",
|
||||
"select": "Versionen",
|
||||
"compatibleVersions": "Kompatible Versionen für {name}",
|
||||
"install": "Installieren",
|
||||
"remove": "Entfernen",
|
||||
"installed": "Installiert",
|
||||
"empty": "Noch keine Add-ons installiert."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +192,8 @@
|
||||
"properties": "Properties",
|
||||
"players": "Players",
|
||||
"worlds": "Worlds",
|
||||
"backups": "Backups"
|
||||
"backups": "Backups",
|
||||
"addons": "Add-ons"
|
||||
},
|
||||
"console": {
|
||||
"loading": "Loading…",
|
||||
@@ -317,6 +318,23 @@
|
||||
"created": "Created",
|
||||
"actions": "Actions",
|
||||
"empty": "No backups yet."
|
||||
},
|
||||
"addons": {
|
||||
"loading": "Loading…",
|
||||
"unsupported": "Add-ons are not supported for this server software yet. Use Paper or Fabric.",
|
||||
"stoppedHint": "Add-ons can only be installed or removed while the server is stopped.",
|
||||
"searchPlaceholder": "Search Modrinth…",
|
||||
"search": "Search",
|
||||
"searchError": "Catalog search failed.",
|
||||
"versionsError": "Could not load versions.",
|
||||
"installError": "Installation failed.",
|
||||
"removeError": "Remove failed.",
|
||||
"select": "Versions",
|
||||
"compatibleVersions": "Compatible versions for {name}",
|
||||
"install": "Install",
|
||||
"remove": "Remove",
|
||||
"installed": "Installed",
|
||||
"empty": "No add-ons installed yet."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
apps/web/src/app/[locale]/servers/[id]/addons/page.tsx
Normal file
13
apps/web/src/app/[locale]/servers/[id]/addons/page.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { setRequestLocale } from "next-intl/server";
|
||||
import { ServerAddons } from "@/components/servers/server-addons";
|
||||
|
||||
interface ServerAddonsPageProps {
|
||||
params: Promise<{ locale: string; id: string }>;
|
||||
}
|
||||
|
||||
export default async function ServerAddonsPage({ params }: ServerAddonsPageProps) {
|
||||
const { locale, id } = await params;
|
||||
setRequestLocale(locale);
|
||||
|
||||
return <ServerAddons serverId={id} />;
|
||||
}
|
||||
222
apps/web/src/components/servers/server-addons.tsx
Normal file
222
apps/web/src/components/servers/server-addons.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
import { Button } from "@hexahost/ui";
|
||||
|
||||
import {
|
||||
installAddon,
|
||||
listCatalogVersions,
|
||||
listInstalledAddons,
|
||||
removeAddon,
|
||||
searchCatalog,
|
||||
type CatalogProject,
|
||||
type CatalogVersion,
|
||||
} from "@/lib/api/addons";
|
||||
import { getServer } from "@/lib/api/servers";
|
||||
|
||||
interface ServerAddonsProps {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function ServerAddons({ serverId }: ServerAddonsProps) {
|
||||
const t = useTranslations("servers.addons");
|
||||
const [query, setQuery] = useState("");
|
||||
const [results, setResults] = useState<CatalogProject[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<CatalogProject | null>(null);
|
||||
const [versions, setVersions] = useState<CatalogVersion[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const { data: server } = useQuery({
|
||||
queryKey: ["server", serverId],
|
||||
queryFn: () => getServer(serverId),
|
||||
});
|
||||
|
||||
const refreshInstalled = useCallback(async () => {
|
||||
const data = await listInstalledAddons(serverId);
|
||||
return data.addons;
|
||||
}, [serverId]);
|
||||
|
||||
const [installed, setInstalled] = useState<Awaited<ReturnType<typeof refreshInstalled>>>([]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshInstalled().then(setInstalled).catch(() => undefined);
|
||||
}, [refreshInstalled]);
|
||||
|
||||
const supportsAddons =
|
||||
server &&
|
||||
["PAPER", "PURPUR", "FABRIC", "FORGE", "NEOFORGE", "QUILT"].includes(
|
||||
server.softwareFamily,
|
||||
);
|
||||
|
||||
async function handleSearch(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!server || !supportsAddons || !query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await searchCatalog({
|
||||
q: query.trim(),
|
||||
gameVersion: server.minecraftVersion,
|
||||
softwareFamily: server.softwareFamily,
|
||||
});
|
||||
setResults(response.projects);
|
||||
setSelectedProject(null);
|
||||
setVersions([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("searchError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectProject(project: CatalogProject) {
|
||||
setSelectedProject(project);
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await listCatalogVersions(project.id, serverId);
|
||||
setVersions(response.versions.filter((version) => version.compatible));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("versionsError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleInstall(version: CatalogVersion) {
|
||||
if (!selectedProject) {
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await installAddon(serverId, selectedProject.id, version.id);
|
||||
setInstalled(await refreshInstalled());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("installError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemove(addonId: string) {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await removeAddon(serverId, addonId);
|
||||
setInstalled(await refreshInstalled());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : t("removeError"));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (!server) {
|
||||
return <p className="text-sm text-zinc-500">{t("loading")}</p>;
|
||||
}
|
||||
|
||||
if (!supportsAddons) {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("unsupported")}</p>;
|
||||
}
|
||||
|
||||
if (server.status !== "STOPPED") {
|
||||
return <p className="text-sm text-zinc-600 dark:text-zinc-400">{t("stoppedHint")}</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}
|
||||
|
||||
<form onSubmit={(event) => void handleSearch(event)} className="flex gap-2">
|
||||
<input
|
||||
className="flex-1 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm dark:border-zinc-700 dark:bg-zinc-950"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t("searchPlaceholder")}
|
||||
/>
|
||||
<Button type="submit" disabled={busy}>
|
||||
{t("search")}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{results.length > 0 ? (
|
||||
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{results.map((project) => (
|
||||
<li key={project.id} className="flex items-center justify-between gap-3 px-4 py-3">
|
||||
<div>
|
||||
<p className="font-medium text-sm">{project.name}</p>
|
||||
<p className="text-xs text-zinc-500 line-clamp-2">{project.description}</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleSelectProject(project)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("select")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{selectedProject && versions.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium">{t("compatibleVersions", { name: selectedProject.name })}</h3>
|
||||
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{versions.map((version) => (
|
||||
<li key={version.id} className="flex items-center justify-between px-4 py-3 text-sm">
|
||||
<span>{version.versionNumber}</span>
|
||||
<Button size="sm" onClick={() => void handleInstall(version)} disabled={busy}>
|
||||
{t("install")}
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<h3 className="mb-2 text-sm font-medium">{t("installed")}</h3>
|
||||
<ul className="divide-y divide-zinc-200 rounded-lg border border-zinc-200 dark:divide-zinc-800 dark:border-zinc-800">
|
||||
{installed.length === 0 ? (
|
||||
<li className="px-4 py-6 text-center text-sm text-zinc-500">{t("empty")}</li>
|
||||
) : (
|
||||
installed.map((addon) => (
|
||||
<li key={addon.id} className="flex items-center justify-between gap-3 px-4 py-3 text-sm">
|
||||
<div>
|
||||
<p className="font-medium">{addon.projectName}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{addon.versionNumber} · {addon.status}
|
||||
</p>
|
||||
</div>
|
||||
{addon.status === "INSTALLED" ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => void handleRemove(addon.id)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("remove")}
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const tabs = [
|
||||
{ key: "players", suffix: "/players" },
|
||||
{ key: "worlds", suffix: "/worlds" },
|
||||
{ key: "backups", suffix: "/backups" },
|
||||
{ key: "addons", suffix: "/addons" },
|
||||
] as const;
|
||||
|
||||
export function ServerTabs({ serverId }: ServerTabsProps) {
|
||||
|
||||
81
apps/web/src/lib/api/addons.ts
Normal file
81
apps/web/src/lib/api/addons.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { apiFetch } from "./auth";
|
||||
import type { SoftwareFamily } from "./servers";
|
||||
|
||||
export interface CatalogProject {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
projectType: string;
|
||||
downloads: number;
|
||||
iconUrl: string | null;
|
||||
}
|
||||
|
||||
export interface CatalogVersion {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
versionNumber: string;
|
||||
gameVersions: string[];
|
||||
loaders: string[];
|
||||
compatible: boolean;
|
||||
publishedAt: string;
|
||||
}
|
||||
|
||||
export interface InstalledAddon {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
versionNumber: string;
|
||||
addonType: string;
|
||||
fileName: string;
|
||||
status: string;
|
||||
failureReason: string | null;
|
||||
}
|
||||
|
||||
export async function searchCatalog(input: {
|
||||
q: string;
|
||||
gameVersion: string;
|
||||
softwareFamily: SoftwareFamily;
|
||||
}): Promise<{ projects: CatalogProject[] }> {
|
||||
const params = new URLSearchParams({
|
||||
q: input.q,
|
||||
gameVersion: input.gameVersion,
|
||||
softwareFamily: input.softwareFamily,
|
||||
});
|
||||
return apiFetch(`/catalog/projects?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function listCatalogVersions(
|
||||
projectId: string,
|
||||
serverId: string,
|
||||
): Promise<{ versions: CatalogVersion[] }> {
|
||||
const params = new URLSearchParams({ serverId });
|
||||
return apiFetch(`/catalog/projects/${projectId}/versions?${params.toString()}`);
|
||||
}
|
||||
|
||||
export async function listInstalledAddons(
|
||||
serverId: string,
|
||||
): Promise<{ addons: InstalledAddon[] }> {
|
||||
return apiFetch(`/servers/${serverId}/addons`);
|
||||
}
|
||||
|
||||
export async function installAddon(
|
||||
serverId: string,
|
||||
projectId: string,
|
||||
versionId: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/servers/${serverId}/addons`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ projectId, versionId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeAddon(
|
||||
serverId: string,
|
||||
addonId: string,
|
||||
): Promise<unknown> {
|
||||
return apiFetch(`/servers/${serverId}/addons/${addonId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"test": "node --test test/**/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hexahost/catalog": "workspace:*",
|
||||
"@hexahost/config": "workspace:*",
|
||||
"@hexahost/database": "workspace:*",
|
||||
"@hexahost/storage": "workspace:*",
|
||||
|
||||
171
apps/worker/src/handlers/server-addons.ts
Normal file
171
apps/worker/src/handlers/server-addons.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { Job } from 'bullmq';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
getModrinthVersion,
|
||||
isVersionCompatible,
|
||||
pickPrimaryFile,
|
||||
resolveModrinthLoader,
|
||||
} from '@hexahost/catalog';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { sendAgentRequest } from '../agent-bridge';
|
||||
import { logger } from '../logger';
|
||||
|
||||
const installAddonJobSchema = z.object({
|
||||
addonId: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const removeAddonJobSchema = z.object({
|
||||
addonId: z.string().uuid(),
|
||||
serverId: z.string().uuid(),
|
||||
});
|
||||
|
||||
const ADDON_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
export async function processInstallAddonJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'install-addon') {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { addonId, serverId } = installAddonJobSchema.parse(job.data);
|
||||
const addon = await prisma.installedAddon.findUniqueOrThrow({
|
||||
where: { id: addonId },
|
||||
});
|
||||
const server = await prisma.gameServer.findUniqueOrThrow({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||
}
|
||||
|
||||
await prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: { status: 'INSTALLING' },
|
||||
});
|
||||
|
||||
try {
|
||||
const version = await getModrinthVersion(addon.versionId);
|
||||
const loader = resolveModrinthLoader(server.softwareFamily);
|
||||
|
||||
if (!loader || !isVersionCompatible(version, server.minecraftVersion, loader)) {
|
||||
throw new Error('Selected version is not compatible with this server');
|
||||
}
|
||||
|
||||
const file = pickPrimaryFile(version);
|
||||
if (!file) {
|
||||
throw new Error('No installable file found for this version');
|
||||
}
|
||||
|
||||
const response = await sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.addon.install',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
downloadUrl: file.url,
|
||||
relativePath: addon.filePath,
|
||||
sha512: file.hashes['sha512'] ?? addon.fileSha512 ?? '',
|
||||
},
|
||||
ADDON_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.errorMessage ?? 'Addon install failed');
|
||||
}
|
||||
|
||||
await prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: {
|
||||
status: 'INSTALLED',
|
||||
installedAt: new Date(),
|
||||
fileSha512: file.hashes['sha512'] ?? addon.fileSha512,
|
||||
},
|
||||
});
|
||||
|
||||
return { status: 'installed' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Addon install failed';
|
||||
await prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
failureReason: message,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processRemoveAddonJob(job: Job): Promise<{ status: string }> {
|
||||
if (job.name !== 'remove-addon') {
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
|
||||
const { addonId, serverId } = removeAddonJobSchema.parse(job.data);
|
||||
const addon = await prisma.installedAddon.findUniqueOrThrow({
|
||||
where: { id: addonId },
|
||||
});
|
||||
const server = await prisma.gameServer.findUniqueOrThrow({
|
||||
where: { id: serverId },
|
||||
});
|
||||
|
||||
if (!server.nodeId) {
|
||||
throw new Error(`Server ${serverId} is not assigned to a node`);
|
||||
}
|
||||
|
||||
await prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: { status: 'REMOVING' },
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await sendAgentRequest(
|
||||
server.nodeId,
|
||||
'server.addon.remove',
|
||||
{
|
||||
serverId,
|
||||
generation: server.version,
|
||||
relativePath: addon.filePath,
|
||||
},
|
||||
ADDON_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.errorMessage ?? 'Addon remove failed');
|
||||
}
|
||||
|
||||
await prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: {
|
||||
status: 'REMOVED',
|
||||
},
|
||||
});
|
||||
|
||||
return { status: 'removed' };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Addon remove failed';
|
||||
await prisma.installedAddon.update({
|
||||
where: { id: addonId },
|
||||
data: {
|
||||
status: 'FAILED',
|
||||
failureReason: message,
|
||||
},
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function processAddonJob(job: Job): Promise<{ status: string }> {
|
||||
switch (job.name) {
|
||||
case 'install-addon':
|
||||
return processInstallAddonJob(job);
|
||||
case 'remove-addon':
|
||||
return processRemoveAddonJob(job);
|
||||
default:
|
||||
logger.warn({ jobName: job.name }, 'Unknown addon job');
|
||||
return { status: 'ignored' };
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { validateConfig } from '@hexahost/config';
|
||||
import { prisma } from '@hexahost/database';
|
||||
|
||||
import { processNotificationJob } from './handlers/notifications';
|
||||
import { processAddonJob } from './handlers/server-addons';
|
||||
import { processBackupJob, processScheduledBackupsTick } from './handlers/server-backups';
|
||||
import { processLifecycleJob } from './handlers/server-lifecycle';
|
||||
import { processProvisionServerJob } from './handlers/server-provisioning';
|
||||
@@ -12,6 +13,7 @@ import { logger } from './logger';
|
||||
import { closeRedisConnections } from './redis';
|
||||
import {
|
||||
QUEUE_NOTIFICATIONS,
|
||||
QUEUE_SERVER_ADDONS,
|
||||
QUEUE_SERVER_BACKUPS,
|
||||
QUEUE_SERVER_LIFECYCLE,
|
||||
QUEUE_SERVER_PROVISIONING,
|
||||
@@ -43,6 +45,10 @@ function createQueueWorker(
|
||||
return processBackupJob(job);
|
||||
}
|
||||
|
||||
if (queueName === QUEUE_SERVER_ADDONS) {
|
||||
return processAddonJob(job);
|
||||
}
|
||||
|
||||
if (queueName === QUEUE_SERVER_LIFECYCLE) {
|
||||
return processLifecycleJob(job);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export const QUEUE_SERVER_ADDONS = 'server-addons' as const;
|
||||
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;
|
||||
@@ -8,6 +9,7 @@ import { Queue } from 'bullmq';
|
||||
import { validateConfig } from '@hexahost/config';
|
||||
|
||||
export const WORKER_QUEUES = [
|
||||
QUEUE_SERVER_ADDONS,
|
||||
QUEUE_SERVER_BACKUPS,
|
||||
QUEUE_SERVER_LIFECYCLE,
|
||||
QUEUE_SERVER_PROVISIONING,
|
||||
@@ -16,6 +18,22 @@ export const WORKER_QUEUES = [
|
||||
|
||||
export type WorkerQueueName = (typeof WORKER_QUEUES)[number];
|
||||
|
||||
let addonsQueue: Queue | null = null;
|
||||
|
||||
export function getAddonsQueue(): Queue {
|
||||
if (!addonsQueue) {
|
||||
const config = validateConfig();
|
||||
addonsQueue = new Queue(QUEUE_SERVER_ADDONS, {
|
||||
connection: {
|
||||
url: config.REDIS_URL,
|
||||
maxRetriesPerRequest: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return addonsQueue;
|
||||
}
|
||||
|
||||
let backupsQueue: Queue | null = null;
|
||||
|
||||
export function getBackupsQueue(): Queue {
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user