Polish module actions and marketplace updates
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user