feat(appstore,workspace): add App Store upgrade progress tracking and interactive workspace processes
This commit is contained in:
@@ -3,15 +3,205 @@ 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<string, IAppStoreUpgradeOperation>();
|
||||
|
||||
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<IAppStoreUpgradeOperation> {
|
||||
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<IAppStoreUpgradeOperation> = {},
|
||||
): Promise<IAppStoreUpgradeOperation> {
|
||||
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<void> {
|
||||
await this.opsServerRef.pushDashboardEvent('pushAppStoreUpgradeProgress', {
|
||||
operation: operationArg,
|
||||
});
|
||||
}
|
||||
|
||||
private async performUpgrade(operationIdArg: string): Promise<interfaces.data.IService> {
|
||||
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<interfaces.requests.IReq_GetAppStoreTemplates>(
|
||||
@@ -66,44 +256,38 @@ export class AppStoreHandler {
|
||||
'upgradeAppStoreService',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
|
||||
const existingService = this.opsServerRef.oneboxRef.database.getServiceByName(dataArg.serviceName);
|
||||
if (!existingService) {
|
||||
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${dataArg.serviceName}`);
|
||||
}
|
||||
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');
|
||||
}
|
||||
|
||||
logger.info(`Upgrading service '${dataArg.serviceName}' from v${existingService.appTemplateVersion} to v${dataArg.targetVersion}`);
|
||||
|
||||
const migrationResult = await this.opsServerRef.oneboxRef.appStore.executeMigration(
|
||||
existingService,
|
||||
existingService.appTemplateVersion,
|
||||
dataArg.targetVersion,
|
||||
);
|
||||
|
||||
if (!migrationResult.success) {
|
||||
throw new plugins.typedrequest.TypedResponseError(
|
||||
`Migration failed: ${migrationResult.warnings.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const updatedService = await this.opsServerRef.oneboxRef.appStore.applyUpgrade(
|
||||
dataArg.serviceName,
|
||||
migrationResult,
|
||||
dataArg.targetVersion,
|
||||
);
|
||||
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: migrationResult.warnings,
|
||||
warnings: completedOperation.warnings,
|
||||
};
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
this.typedrouter.addTypedHandler(
|
||||
new plugins.typedrequest.TypedHandler<interfaces.requests.IReq_StartAppStoreServiceUpgrade>(
|
||||
'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<interfaces.requests.IReq_GetAppStoreUpgradeOperations>(
|
||||
'getAppStoreUpgradeOperations',
|
||||
async (dataArg) => {
|
||||
await requireAdminIdentity(this.opsServerRef.adminHandler, dataArg);
|
||||
return { operations: this.getUpgradeOperations() };
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user