import * as plugins from '../../plugins.ts'; import { logger } from '../../logging.ts'; import type { OpsServer } from '../classes.opsserver.ts'; import * as interfaces from '../../../ts_interfaces/index.ts'; import { requireAdminIdentity } from '../helpers/guards.ts'; import { getErrorMessage } from '../../utils/error.ts'; type IAppStoreUpgradeOperation = interfaces.requests.IAppStoreUpgradeOperation; type TAppStoreUpgradeStep = interfaces.requests.TAppStoreUpgradeStep; export class AppStoreHandler { public typedrouter = new plugins.typedrequest.TypedRouter(); private upgradeOperations = new Map(); constructor(private opsServerRef: OpsServer) { this.opsServerRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } private getUpgradeOperations(): IAppStoreUpgradeOperation[] { return Array.from(this.upgradeOperations.values()) .sort((a, b) => b.startedAt - a.startedAt) .slice(0, 25); } private getRunningUpgrade(serviceNameArg: string): IAppStoreUpgradeOperation | null { for (const operation of this.upgradeOperations.values()) { if (operation.serviceName === serviceNameArg && operation.status === 'running') { return operation; } } return null; } private async createUpgradeOperation( serviceNameArg: string, targetVersionArg: string, ): Promise { const existingRunning = this.getRunningUpgrade(serviceNameArg); if (existingRunning) { throw new plugins.typedrequest.TypedResponseError( `An upgrade is already running for ${serviceNameArg}`, ); } const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(serviceNameArg); if (!existingService) { throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceNameArg}`); } if (!existingService.appTemplateId) { throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an app template'); } if (!existingService.appTemplateVersion) { throw new plugins.typedrequest.TypedResponseError('Service has no tracked template version'); } const now = Date.now(); const operation: IAppStoreUpgradeOperation = { id: crypto.randomUUID(), serviceName: existingService.name, appTemplateId: existingService.appTemplateId, fromVersion: existingService.appTemplateVersion, targetVersion: targetVersionArg, status: 'running', step: 'queued', progressLines: [`Queued upgrade ${existingService.appTemplateVersion} -> ${targetVersionArg}`], warnings: [], startedAt: now, updatedAt: now, }; this.upgradeOperations.set(operation.id, operation); await this.pushUpgradeProgress(operation); return operation; } private async updateUpgradeOperation( operationIdArg: string, stepArg: TAppStoreUpgradeStep, messageArg: string, updatesArg: Partial = {}, ): Promise { const existing = this.upgradeOperations.get(operationIdArg); if (!existing) { throw new Error(`Upgrade operation not found: ${operationIdArg}`); } const nextOperation: IAppStoreUpgradeOperation = { ...existing, ...updatesArg, step: stepArg, updatedAt: Date.now(), progressLines: [...existing.progressLines, messageArg].slice(-200), }; this.upgradeOperations.set(operationIdArg, nextOperation); await this.pushUpgradeProgress(nextOperation); return nextOperation; } private async pushUpgradeProgress(operationArg: IAppStoreUpgradeOperation): Promise { await this.opsServerRef.pushDashboardEvent('pushAppStoreUpgradeProgress', { operation: operationArg, }); } private async performUpgrade(operationIdArg: string): Promise { let operation = this.upgradeOperations.get(operationIdArg); if (!operation) { throw new Error(`Upgrade operation not found: ${operationIdArg}`); } try { operation = await this.updateUpgradeOperation( operation.id, 'validating', `Validating ${operation.serviceName} for App Store upgrade`, ); const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(operation.serviceName); if (!existingService) { throw new Error(`Service not found: ${operation.serviceName}`); } if (!existingService.appTemplateId || !existingService.appTemplateVersion) { throw new Error('Service is missing App Store template metadata'); } logger.info( `Upgrading service '${operation.serviceName}' from v${operation.fromVersion} to v${operation.targetVersion}`, ); await this.updateUpgradeOperation( operation.id, 'migration', `Resolving migration for ${operation.appTemplateId} ${operation.fromVersion} -> ${operation.targetVersion}`, ); const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration( existingService, operation.fromVersion, operation.targetVersion, ); if (!migrationResult.success) { throw new Error(`Migration failed: ${migrationResult.warnings.join('; ')}`); } if (migrationResult.warnings.length > 0) { operation = await this.updateUpgradeOperation( operation.id, 'migration', `Migration completed with ${migrationResult.warnings.length} warning(s)`, { warnings: migrationResult.warnings }, ); } await this.updateUpgradeOperation( operation.id, 'applying', `Applying upgrade to ${operation.serviceName}`, ); const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade( operation.serviceName, migrationResult, operation.targetVersion, { onProgress: async (progressArg) => { await this.updateUpgradeOperation( operation!.id, progressArg.step as TAppStoreUpgradeStep, progressArg.message, ); }, }, ); await this.updateUpgradeOperation( operation.id, 'complete', `Upgrade completed for ${operation.serviceName}`, { status: 'success', completedAt: Date.now(), service: updatedService, warnings: migrationResult.warnings, }, ); return updatedService; } catch (error) { await this.updateUpgradeOperation( operation.id, 'failed', `Upgrade failed: ${getErrorMessage(error)}`, { status: 'failed', completedAt: Date.now(), error: getErrorMessage(error), }, ); throw error; } } private registerHandlers(): void { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreTemplates', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const apps = await this.opsServerRef.oneboxRef.appStore.getApps(); return { apps }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreConfig', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const config = await this.opsServerRef.oneboxRef.appStore.getAppVersionConfig( dataArg.appId, dataArg.version, ); const appMeta = await this.opsServerRef.oneboxRef.appStore.getAppMeta(dataArg.appId); return { config, appMeta }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'installAppStoreApp', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const service = await this.opsServerRef.oneboxRef.appStore.installApp(dataArg.install); return { service }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getUpgradeableAppStoreServices', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const services = await this.opsServerRef.oneboxRef.appStore.getUpgradeableAppStoreServices(); return { services }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'upgradeAppStoreService', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const operation = await this.createUpgradeOperation(dataArg.serviceName, dataArg.targetVersion); const updatedService = await this.performUpgrade(operation.id); const completedOperation = this.upgradeOperations.get(operation.id)!; return { service: updatedService, warnings: completedOperation.warnings, }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'startAppStoreServiceUpgrade', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); const operation = await this.createUpgradeOperation(dataArg.serviceName, dataArg.targetVersion); void this.performUpgrade(operation.id).catch(() => {}); return { operation }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreUpgradeOperations', async (dataArg) => { await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg); return { operations: this.getUpgradeOperations() }; }, ), ); } }