import type { Cloudly } from '../classes.cloudly.js'; import * as plugins from '../plugins.js'; import { Service } from '../manager.service/classes.service.js'; type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifecycleState; type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState; type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity; interface IHostedAppParentUpgradeResponse { isHosted: boolean; unavailableReason?: string; upgradeState: IHostedAppUpgradeState; } type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & { hostedAppLifecycle?: IHostedAppLifecycleState; }; export class CloudlyHostedAppManager { public typedrouter = new plugins.typedrequest.TypedRouter(); constructor(private cloudlyRef: Cloudly) { this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } public async start() {} public async stop() {} private getParentRuntimeIdentity(): IHostedAppRuntimeIdentity | null { const appInstanceId = process.env.SERVEZONE_APP_INSTANCE_ID; const appControlToken = process.env.SERVEZONE_APP_CONTROL_TOKEN; if (!appInstanceId || !appControlToken) { return null; } return { appInstanceId, appControlToken, hostType: process.env.SERVEZONE_APP_HOST_TYPE || 'onebox', }; } private createParentRuntimeTypedRequest(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest | null { const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL; if (!runtimeUrl) { return null; } return new plugins.typedrequest.TypedRequest( `${runtimeUrl.replace(/\/+$/, '')}/typedrequest`, methodArg, ); } private getParentRuntimeUnavailableReason(): string | undefined { if (!process.env.SERVEZONE_RUNTIME_URL) { return 'SERVEZONE_RUNTIME_URL is not configured.'; } if (!process.env.SERVEZONE_APP_INSTANCE_ID || !process.env.SERVEZONE_APP_CONTROL_TOKEN) { return 'Hosted app runtime identity is not configured.'; } return undefined; } private getErrorMessage(errorArg: unknown): string { return errorArg instanceof Error ? errorArg.message : String(errorArg); } public async getParentUpgradeStatus(): Promise { const unavailableReason = this.getParentRuntimeUnavailableReason(); const identity = this.getParentRuntimeIdentity(); const request = this.createParentRuntimeTypedRequest( 'hostedAppGetManagedUpgradeStatus', ); if (unavailableReason || !identity || !request) { return { isHosted: false, unavailableReason, upgradeState: { status: 'unknown' }, }; } try { const response = await request.fire({ identity }); return { isHosted: true, upgradeState: response.upgradeState, }; } catch (error) { const message = this.getErrorMessage(error); return { isHosted: true, unavailableReason: message, upgradeState: { status: 'unknown', error: message, }, }; } } public async startParentUpgrade(targetVersionArg?: string): Promise { const unavailableReason = this.getParentRuntimeUnavailableReason(); const identity = this.getParentRuntimeIdentity(); const request = this.createParentRuntimeTypedRequest( 'hostedAppStartManagedUpgrade', ); if (unavailableReason || !identity || !request) { return { isHosted: false, unavailableReason, upgradeState: { status: 'unknown' }, }; } try { const response = await request.fire({ identity, targetVersion: targetVersionArg, }); return { isHosted: true, upgradeState: response.upgradeState, }; } catch (error) { const message = this.getErrorMessage(error); return { isHosted: true, unavailableReason: message, upgradeState: { status: 'failed', error: message, }, }; } } public async requestParentInitialAdminBootstrap(): Promise<{ username: string; password: string; actionId: string; } | null> { const identity = this.getParentRuntimeIdentity(); const request = this.createParentRuntimeTypedRequest( 'hostedAppRequestBootstrapAction', ); if (!identity || !request) { return null; } const username = 'admin'; const password = plugins.smartunique.uniSimple('cloudlyadmin', 32); const response = await request.fire({ identity, action: { type: 'credentials', label: 'Cloudly initial admin', url: `https://${this.cloudlyRef.config.data.publicUrl}`, username, password, message: 'Use these credentials to sign in to Cloudly, then change the admin password.', }, }); return { username, password, actionId: response.action.id, }; } public async completeParentBootstrapAction(actionIdArg?: string, messageArg?: string): Promise { const identity = this.getParentRuntimeIdentity(); const request = this.createParentRuntimeTypedRequest( 'hostedAppCompleteBootstrapAction', ); if (!identity || !request) { return; } await request.fire({ identity, actionId: actionIdArg, message: messageArg, }); } public createHostedAppRuntimeEnvVars(serviceNameArg: string): { appInstanceId: string; appControlToken: string; envVars: Record; lifecycle: IHostedAppLifecycleState; } { const appInstanceId = plugins.smartunique.uniSimple('hostedapp'); const appControlToken = plugins.smartunique.uniSimple('hostedapptoken', 64); const runtimeUrl = `https://${this.cloudlyRef.config.data.publicUrl}`; return { appInstanceId, appControlToken, envVars: { SERVEZONE_RUNTIME_URL: runtimeUrl, SERVEZONE_APP_INSTANCE_ID: appInstanceId, SERVEZONE_APP_CONTROL_TOKEN: appControlToken, SERVEZONE_APP_HOST_TYPE: 'cloudly', }, lifecycle: { appInstanceId, hostType: 'cloudly', appName: serviceNameArg, runtimeStatus: 'unknown', }, }; } private async requireHostedAppIdentity(identityArg: IHostedAppRuntimeIdentity): Promise { const services = await this.cloudlyRef.serviceManager.CService.getInstances({}); const service = services.find((serviceArg) => { const serviceData = serviceArg.data as TExtendedServiceData; return ( serviceData.hostedAppLifecycle?.appInstanceId === identityArg?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID === identityArg?.appInstanceId ); }); if (!service) { throw new plugins.typedrequest.TypedResponseError('Hosted app service not found'); } const serviceData = service.data as TExtendedServiceData; if (serviceData.environment?.SERVEZONE_APP_CONTROL_TOKEN !== identityArg?.appControlToken) { throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid'); } return service; } private async getUpgradeState(serviceArg: Service): Promise { const serviceData = serviceArg.data as TExtendedServiceData; const latestOperation = this.cloudlyRef.appStoreManager .getUpgradeOperations() .find((operationArg) => operationArg.serviceId === serviceArg.id); if (latestOperation) { return { status: latestOperation.status === 'running' ? 'running' : latestOperation.status, appTemplateId: latestOperation.appTemplateId, currentVersion: latestOperation.fromVersion, targetVersion: latestOperation.targetVersion, operationId: latestOperation.id, warnings: latestOperation.warnings, error: latestOperation.error, startedAt: latestOperation.startedAt, updatedAt: latestOperation.updatedAt, completedAt: latestOperation.completedAt, }; } if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) { return { status: 'unknown' }; } const upgradeableServices = await this.cloudlyRef.appStoreManager.getUpgradeableAppStoreServices(); const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceId === serviceArg.id); if (!upgradeable) { return { status: 'upToDate', appTemplateId: serviceData.appTemplateId, currentVersion: serviceData.appTemplateVersion, latestVersion: serviceData.appTemplateVersion, }; } return { status: 'available', appTemplateId: upgradeable.appTemplateId, currentVersion: upgradeable.currentVersion, latestVersion: upgradeable.latestVersion, targetVersion: upgradeable.latestVersion, }; } private async getLifecycleState(serviceArg: Service): Promise { const serviceData = serviceArg.data as TExtendedServiceData; const appInstanceId = serviceData.hostedAppLifecycle?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID; const state: IHostedAppLifecycleState = { ...(serviceData.hostedAppLifecycle || ({} as IHostedAppLifecycleState)), appInstanceId: appInstanceId || '', hostType: 'cloudly', appName: serviceData.hostedAppLifecycle?.appName || serviceData.name, publicUrl: serviceData.hostedAppLifecycle?.publicUrl || (serviceData.domains?.[0]?.name ? `https://${serviceData.domains[0].name}` : undefined), upgradeState: await this.getUpgradeState(serviceArg), }; serviceData.hostedAppLifecycle = state; serviceArg.data = serviceData; await serviceArg.save(); return state; } private async updateLifecycleState(serviceArg: Service, stateArg: IHostedAppLifecycleState): Promise { const serviceData = serviceArg.data as TExtendedServiceData; serviceData.hostedAppLifecycle = stateArg; serviceArg.data = serviceData; await serviceArg.save(); return await this.getLifecycleState(serviceArg); } private registerHandlers() { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'hostedAppReportLifecycleState', async (dataArg) => { const service = await this.requireHostedAppIdentity(dataArg.identity); const existingState = await this.getLifecycleState(service); const state = await this.updateLifecycleState(service, { ...existingState, ...dataArg.report, appInstanceId: existingState.appInstanceId, hostType: 'cloudly', reportedAt: Date.now(), }); return { state }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'hostedAppGetLifecycleState', async (dataArg) => { const service = await this.requireHostedAppIdentity(dataArg.identity); return { state: await this.getLifecycleState(service) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'hostedAppRequestBootstrapAction', async (dataArg) => { const service = await this.requireHostedAppIdentity(dataArg.identity); const existingState = await this.getLifecycleState(service); const now = Date.now(); const action = { ...dataArg.action, id: dataArg.action.id || plugins.smartunique.shortId(12), status: 'ready' as const, label: dataArg.action.label || 'Initial setup', createdAt: now, updatedAt: now, }; const state = await this.updateLifecycleState(service, { ...existingState, runtimeStatus: 'setupRequired', bootstrapAction: action, reportedAt: now, }); return { action, state }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'hostedAppCompleteBootstrapAction', async (dataArg) => { const service = await this.requireHostedAppIdentity(dataArg.identity); const existingState = await this.getLifecycleState(service); const now = Date.now(); const bootstrapAction = existingState.bootstrapAction ? { ...existingState.bootstrapAction, id: dataArg.actionId || existingState.bootstrapAction.id, status: 'completed' as const, message: dataArg.message || existingState.bootstrapAction.message, updatedAt: now, completedAt: now, } : undefined; const state = await this.updateLifecycleState(service, { ...existingState, runtimeStatus: existingState.runtimeStatus === 'setupRequired' ? 'running' : existingState.runtimeStatus, bootstrapAction, reportedAt: now, }); return { state }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'hostedAppStartManagedUpgrade', async (dataArg) => { const service = await this.requireHostedAppIdentity(dataArg.identity); const upgradeState = await this.getUpgradeState(service); const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion; if (!targetVersion) { throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available'); } const operation = await this.cloudlyRef.appStoreManager.startHostedAppUpgrade(service.id, targetVersion); const nextUpgradeState: IHostedAppUpgradeState = { status: 'running', appTemplateId: operation.appTemplateId, currentVersion: operation.fromVersion, targetVersion: operation.targetVersion, operationId: operation.id, warnings: operation.warnings, startedAt: operation.startedAt, updatedAt: operation.updatedAt, }; const existingState = await this.getLifecycleState(service); const state = await this.updateLifecycleState(service, { ...existingState, upgradeState: nextUpgradeState, reportedAt: Date.now(), }); return { upgradeState: nextUpgradeState, state }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'hostedAppGetManagedUpgradeStatus', async (dataArg) => { const service = await this.requireHostedAppIdentity(dataArg.identity); return { upgradeState: await this.getUpgradeState(service) }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getHostedAppParentUpgradeStatus', async (dataArg) => { await this.passAdminIdentity(dataArg); return await this.getParentUpgradeStatus(); }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'startHostedAppParentUpgrade', async (dataArg) => { await this.passAdminIdentity(dataArg); return await this.startParentUpgrade(dataArg.targetVersion); }, ), ); } private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.adminIdentityGuard, ]); } }