Polish module actions and marketplace updates

This commit is contained in:
leon
2026-10-09 23:43:05 +02:00
parent fd9de823ea
commit 84d8697f23
11 changed files with 244 additions and 101 deletions

View File

@@ -77,19 +77,43 @@ export class MarketplaceController {
return this.marketplaceService.availableUpdates(moduleId);
}
@Get('operations/:operationId')
@Roles('ADMIN')
operationProgress(@Param('operationId') operationId: string, @CurrentUser() actor: AuthUser) {
return this.marketplaceService.getOperationProgress(operationId, actor.id);
}
@Post('modules/:moduleId/update')
@Roles('ADMIN')
async updateModule(
@Param('moduleId') moduleId: string,
@Body() body: { branch?: unknown },
@Body() body: { branch?: unknown; operationId?: unknown },
@CurrentUser() actor: AuthUser,
@Req() request: AuthenticatedRequest,
): Promise<{ module: ModuleRecord }> {
if (typeof body.branch !== 'string') throw new BadRequestException('Bitte eine Update-Branch auswählen');
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch);
const module = await this.modulesService.updateFromMarketplace(moduleId, update.archive, actor, request.ip ?? null);
await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit);
return { module };
const operationId = this.marketplaceService.beginOperation(body.operationId, actor.id);
const report = (phase: string, message: string, progress: number) =>
this.marketplaceService.reportOperation(operationId, actor.id, phase, message, progress);
try {
report('branch', 'Branch wird geprüft', 8);
const update = await this.marketplaceService.updateInstalledBranch(moduleId, body.branch, report);
report('validation', 'Update-Paket wird geprüft', 40);
const module = await this.modulesService.updateFromMarketplace(
moduleId,
update.archive,
actor,
request.ip ?? null,
report,
);
report('commit', 'Neue Version wird registriert', 96);
await this.marketplaceService.commitInstalledBranch(moduleId, update.provider, update.owner, update.repository, update.branch, update.commit);
this.marketplaceService.finishOperation(operationId, actor.id, true);
return { module };
} catch (error) {
this.marketplaceService.finishOperation(operationId, actor.id, false);
throw error;
}
}
@Post('connections/:provider/start')

View File

@@ -54,6 +54,13 @@ export interface MarketplaceUpdatePackage {
branch: string;
}
export interface MarketplaceOperationProgress {
status: 'running' | 'completed' | 'failed';
message: string;
phase: string;
progress: number;
}
interface MarketplaceInstallationRow {
module_id: string;
provider: MarketplaceProvider;
@@ -106,6 +113,7 @@ function isNewerVersionBranch(candidate: string, installed: string): boolean {
export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(MarketplaceService.name);
private branchCheckTimer: NodeJS.Timeout | undefined;
private readonly operationProgress = new Map<string, { userId: string; state: MarketplaceOperationProgress }>();
constructor(
private readonly database: DatabaseService,
@@ -123,6 +131,37 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (this.branchCheckTimer) clearInterval(this.branchCheckTimer);
}
beginOperation(operationId: unknown, userId: string): string | null {
if (typeof operationId !== 'string' || !/^[0-9a-f-]{36}$/i.test(operationId)) return null;
const state: MarketplaceOperationProgress = { status: 'running', phase: 'starting', message: 'Update wird vorbereitet', progress: 3 };
this.operationProgress.set(operationId, { userId, state });
return operationId;
}
reportOperation(operationId: string | null, userId: string, phase: string, message: string, progress: number): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId || operation.state.status !== 'running') return;
operation.state = { status: 'running', phase, message, progress: Math.max(0, Math.min(99, progress)) };
}
finishOperation(operationId: string | null, userId: string, success: boolean): void {
if (!operationId) return;
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) return;
operation.state = success
? { status: 'completed', phase: 'completed', message: 'Installation abgeschlossen', progress: 100 }
: { ...operation.state, status: 'failed', phase: 'failed', message: 'Installation fehlgeschlagen' };
const cleanup = setTimeout(() => this.operationProgress.delete(operationId), 15 * 60 * 1000);
cleanup.unref();
}
getOperationProgress(operationId: string, userId: string): MarketplaceOperationProgress {
const operation = this.operationProgress.get(operationId);
if (!operation || operation.userId !== userId) throw new NotFoundException('Installationsvorgang wurde nicht gefunden');
return operation.state;
}
async providers(): Promise<ProviderStatus[]> {
const connected = await this.database.query<{ provider: MarketplaceProvider; account_login: string }>(
'SELECT provider, account_login FROM marketplace_connections',
@@ -344,7 +383,11 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
return this.normalizeRepositoryArchive(archive);
}
async updateInstalledBranch(moduleId: string, branch: string): Promise<MarketplaceUpdatePackage> {
async updateInstalledBranch(
moduleId: string,
branch: string,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<MarketplaceUpdatePackage> {
const result = await this.database.query<MarketplaceInstallationRow>(
`SELECT module_id, provider, owner, repository, installed_branch, installed_commit, available_branches
FROM marketplace_module_installations WHERE module_id = $1`, [moduleId],
@@ -359,10 +402,12 @@ export class MarketplaceService implements OnModuleInit, OnModuleDestroy {
if (!Array.isArray(installation.available_branches) || !installation.available_branches.some((item) => item.name === branch)) {
throw new BadRequestException('Diese Branch wurde bei der letzten Repository-Prüfung nicht als Update gefunden');
}
onProgress?.('branch', 'Branch wird geprüft', 12);
const branchInfo = await this.getBranch(installation.provider, installation.owner, installation.repository, branch);
if (installation.installed_commit && branchInfo.commit.toLowerCase() === installation.installed_commit.toLowerCase()) {
throw new BadRequestException('Diese Branch enthält keine Änderungen gegenüber der installierten Version');
}
onProgress?.('download', 'Update-Archiv wird geladen', 28);
const archive = await this.downloadRepositoryArchive(installation.provider, installation.owner, installation.repository, branch);
// Caller updates and validates the module files before this source record is advanced.
return { archive, commit: branchInfo.commit, provider: installation.provider,

View File

@@ -149,7 +149,9 @@ export class ModulesService {
packageBuffer: Buffer,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
onProgress?.('validation', 'Update-Paket wird geprüft', 42);
const current = await this.getById(id);
if (['STARTING', 'STOPPING', 'ERROR'].includes(current.status)) {
throw new ConflictException('Das Modul muss einen stabilen Status haben, bevor ein Update gestartet werden kann');
@@ -162,16 +164,20 @@ export class ModulesService {
throw new BadRequestException('Das Update muss Modul-ID, URL-Slug, Port und Compose-Service beibehalten');
}
const wasRunning = current.status === 'RUNNING';
if (wasRunning) await this.stop(id, actor, ipAddress);
if (wasRunning) {
onProgress?.('stopping', 'Laufendes Modul wird gestoppt', 55);
await this.stop(id, actor, ipAddress);
}
let replacement: { directory: string; backupDirectory: string } | undefined;
try {
onProgress?.('replacing', 'Moduldateien werden aktualisiert', 66);
replacement = await this.installer.replace(packageBuffer, manifest, this.config.runtime.modulesDir);
const candidate = { ...current, name: manifest.name, version: manifest.version, description: manifest.description,
author: manifest.author, configuration: manifest.configuration };
const configuration = await this.configurationService.state(candidate);
await this.moduleRepository.updateManifest(id, manifest, configuration.ready);
if (wasRunning) await this.start(id, actor, ipAddress);
if (wasRunning) await this.start(id, actor, ipAddress, onProgress);
await this.installer.finalizeReplacement(replacement.backupDirectory).catch((cleanupError: unknown) => {
this.logger.warn(`Alte Moduldateien für ${current.moduleId} konnten nicht bereinigt werden: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
});
@@ -209,7 +215,12 @@ export class ModulesService {
}
/** Startet ein Modul (INSTALLED/STOPPED → STARTING → RUNNING). */
async start(id: string, actor: ActingUser, ipAddress: string | null): Promise<ModuleRecord> {
async start(
id: string,
actor: ActingUser,
ipAddress: string | null,
onProgress?: (phase: string, message: string, progress: number) => void,
): Promise<ModuleRecord> {
const module = await this.getById(id);
this.assertEnabled(module);
if (module.status === 'RUNNING' || module.status === 'STARTING') {
@@ -219,9 +230,11 @@ export class ModulesService {
await this.moduleRepository.updateStatus(id, 'STARTING');
try {
onProgress?.('starting', 'Modulcontainer werden gestartet', 80);
await this.processManager.start(module);
// Startup-Grace: Der Modul-Prozess braucht einen Moment zum Starten.
// Der Healthcheck wird mit Retries wiederholt, bevor er als Fehlschlag gilt.
onProgress?.('healthcheck', 'Healthcheck läuft', 91);
const health = await this.waitForHealthy(module);
if (!health.healthy) {
throw new Error(`Healthcheck fehlgeschlagen: ${health.detail}`);