import type { Cloudly } from '../classes.cloudly.js'; import * as plugins from '../plugins.js'; import { Image } from '../manager.image/classes.image.js'; import { Service } from '../manager.service/classes.service.js'; import { SecretBundle } from '../manager.secret/classes.secretbundle.js'; import { PlatformBinding } from '../manager.platform/classes.platformbinding.js'; import { commitinfo } from '../00_commitinfo_data.js'; type IAppStoreApp = plugins.servezoneInterfaces.appstore.IAppStoreApp; type IAppStoreIndex = plugins.servezoneInterfaces.appstore.IAppStoreIndex; type IAppStoreAppMeta = plugins.servezoneInterfaces.appstore.IAppStoreAppMeta; type IAppStoreVersionConfig = plugins.servezoneInterfaces.appstore.IAppStoreVersionConfig; type IAppStoreInstallOptions = plugins.servezoneInterfaces.appstore.IAppStoreInstallRequest; type IAppStorePublishedPort = plugins.servezoneInterfaces.appstore.IAppStorePublishedPort; type IUpgradeableAppStoreService = Omit & { serviceId: string; }; type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & { appTemplateId?: string; appTemplateVersion?: string; appStoreUpgradePolicy?: 'manual' | 'notify' | 'auto'; publishedPorts?: IAppStoreVersionConfig['publishedPorts']; }; type TAppStoreUpgradeStatus = 'running' | 'success' | 'failed'; type TAppStoreUpgradeStep = | 'queued' | 'validating' | 'migration' | 'applying' | 'updating-service' | 'pushing-config' | 'complete' | 'failed'; interface IAppStoreUpgradeChange { field: string; currentValue: string; targetValue: string; } interface IAppStoreUpgradePreview { serviceId: string; serviceName: string; appTemplateId: string; fromVersion: string; targetVersion: string; resolvedTargetVersion: string; hasMigration: boolean; requiresManualReview: boolean; changes: IAppStoreUpgradeChange[]; warnings: string[]; blockers: string[]; config: IAppStoreVersionConfig; appMeta: IAppStoreAppMeta; } interface IAppStoreUpgradeOperation { id: string; serviceId: string; serviceName: string; appTemplateId: string; fromVersion: string; targetVersion: string; status: TAppStoreUpgradeStatus; step: TAppStoreUpgradeStep; progressLines: string[]; warnings: string[]; error?: string; startedAt: number; updatedAt: number; completedAt?: number; service?: plugins.servezoneInterfaces.data.IService; } export class CloudlyAppStoreManager { public typedrouter = new plugins.typedrequest.TypedRouter(); private readonly appStoreResolver = new plugins.servezoneAppstore.AppStoreResolver({ baseUrl: process.env.APPSTORE_URL || 'https://code.foss.global/serve.zone/appstore/raw/branch/main', }); private readonly upgradeOperations = new Map(); constructor(private cloudlyRef: Cloudly) { this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter); this.registerHandlers(); } public async start() {} public async stop() {} private registerHandlers() { this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreTemplates', async (dataArg) => { await this.passAdminIdentity(dataArg); return { apps: await this.getApps() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreConfig', async (dataArg) => { await this.passAdminIdentity(dataArg); return { config: await this.getAppVersionConfig(dataArg.appId, dataArg.version), appMeta: await this.getAppMeta(dataArg.appId), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'installAppStoreApp', async (dataArg) => { await this.passAdminIdentity(dataArg); const service = await this.installApp(dataArg.install); return { service: await service.createSavableObject() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getUpgradeableAppStoreServices', async (dataArg) => { await this.passAdminIdentity(dataArg); return { services: await this.getUpgradeableAppStoreServices() }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreUpgradePreview', async (dataArg) => { await this.passAdminIdentity(dataArg); return { preview: await this.getAppStoreUpgradePreview(dataArg.serviceId, dataArg.targetVersion), }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'upgradeAppStoreService', async (dataArg) => { await this.passAdminIdentity(dataArg); const { service, warnings } = await this.applyUpgrade(dataArg.serviceId, dataArg.targetVersion); return { service: await service.createSavableObject(), warnings, }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'startAppStoreServiceUpgrade', async (dataArg) => { await this.passAdminIdentity(dataArg); const operation = await this.createUpgradeOperation(dataArg.serviceId, dataArg.targetVersion); void this.performUpgrade(operation.id).catch(() => {}); return { operation }; }, ), ); this.typedrouter.addTypedHandler( new plugins.typedrequest.TypedHandler( 'getAppStoreUpgradeOperations', async (dataArg) => { await this.passAdminIdentity(dataArg); return { operations: this.getUpgradeOperations() }; }, ), ); } public async getAppStore(): Promise { return await this.appStoreResolver.getAppStoreIndex(); } public async getApps(): Promise { return await this.appStoreResolver.getApps(); } public async getAppMeta(appIdArg: string): Promise { return await this.appStoreResolver.getAppMeta(appIdArg); } public async getAppVersionConfig(appIdArg: string, versionArg?: string): Promise { if (!versionArg) { versionArg = (await this.getAppMeta(appIdArg)).latestVersion; } return await this.appStoreResolver.getAppVersionConfig(appIdArg, versionArg); } public async getUpgradeableAppStoreServices(): Promise { const appStore = await this.getAppStore(); const services = await this.cloudlyRef.serviceManager.CService.getInstances({}); const upgradeableServices: IUpgradeableAppStoreService[] = []; for (const service of services) { const serviceData = service.data as TExtendedServiceData; if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) { continue; } const appStoreApp = appStore.apps.find((appArg) => appArg.id === serviceData.appTemplateId); if (!appStoreApp || appStoreApp.latestVersion === serviceData.appTemplateVersion) { continue; } let hasMigration = false; try { hasMigration = await this.hasMigrationScript( serviceData.appTemplateId, serviceData.appTemplateVersion, appStoreApp.latestVersion, ); } catch { hasMigration = true; } upgradeableServices.push({ serviceId: service.id, serviceName: serviceData.name, appTemplateId: serviceData.appTemplateId, currentVersion: serviceData.appTemplateVersion, latestVersion: appStoreApp.latestVersion, hasMigration, }); } return upgradeableServices; } public getUpgradeOperations(): IAppStoreUpgradeOperation[] { return Array.from(this.upgradeOperations.values()) .sort((a, b) => b.startedAt - a.startedAt) .slice(0, 25); } public async getAppStoreUpgradePreview( serviceIdArg: string, targetVersionArg?: string, ): Promise { const service = await Service.getInstance({ id: serviceIdArg }); if (!service) { throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceIdArg}`); } const serviceData = service.data as TExtendedServiceData; if (!serviceData.appTemplateId) { throw new plugins.typedrequest.TypedResponseError('Service was not deployed from an App Store app'); } if (!serviceData.appTemplateVersion) { throw new plugins.typedrequest.TypedResponseError('Service has no tracked App Store version'); } const appMeta = await this.getAppMeta(serviceData.appTemplateId); const targetVersion = targetVersionArg || appMeta.latestVersion; const config = await this.getAppVersionConfig(serviceData.appTemplateId, targetVersion); const resolvedTargetVersion = config.appStoreVersion || targetVersion; const blockers: string[] = []; const warnings: string[] = []; const changes: IAppStoreUpgradeChange[] = []; let hasMigration = false; try { hasMigration = await this.hasMigrationScript( serviceData.appTemplateId, serviceData.appTemplateVersion, resolvedTargetVersion, ); } catch (error) { blockers.push((error as Error).message); } const requiresManualReview = Boolean( config.requiresManualReview || config.breaking || config.migrationRequired || hasMigration, ); try { this.assertRuntimeCompatibility(config); } catch (error) { blockers.push((error as Error).message); } const unsupportedPlatformRequirements = this.getUnsupportedPlatformRequirements(config); if (unsupportedPlatformRequirements.length > 0) { blockers.push( `Cloudly App Store install does not yet support platform requirement(s): ${unsupportedPlatformRequirements.join(', ')}`, ); } if (config.migrationRequired) { blockers.push('This upgrade declares migrationRequired and needs a Cloudly migration implementation before it can be applied.'); } else if (hasMigration) { blockers.push('A migration script exists for this upgrade. Cloudly migration execution must be implemented before applying it.'); } if (config.breaking) { warnings.push('This App Store version is marked as breaking.'); } if (config.requiresManualReview) { warnings.push('This App Store version requires manual review.'); } if (config.backupBeforeUpgrade) { warnings.push('The template recommends taking a backup before upgrading.'); } const nextEnvVars = this.getMergedUpgradeEnvVars(serviceData, config, blockers); const serviceDomain = serviceData.domains?.[0]?.name; if (this.requiresTemplateValue(nextEnvVars, 'SERVICE_DOMAIN') && !serviceDomain) { blockers.push('A domain is required because the target app template uses ${SERVICE_DOMAIN}'); } this.applyServiceDomainEnv(nextEnvVars, serviceDomain); const nextVolumes = this.mergeUpgradeVolumes(serviceData.volumes || [], config.volumes); const nextPublishedPorts = this.mergeUpgradePublishedPorts(serviceData.publishedPorts || [], config.publishedPorts || []); const unsupportedPublishedPorts = this.getUnsupportedPublishedPorts(nextPublishedPorts); if (unsupportedPublishedPorts.length > 0) { blockers.push(`Cloudly cannot apply published port setting(s): ${unsupportedPublishedPorts.join(', ')}`); } const currentImageRef = await this.getServiceImageReference(service); this.pushChange(changes, 'image', currentImageRef, config.image); this.pushChange(changes, 'appTemplateVersion', serviceData.appTemplateVersion, resolvedTargetVersion); this.pushChange(changes, 'webPort', String(serviceData.ports?.web || ''), String(config.port)); this.pushChange( changes, 'environment', this.stableStringify(serviceData.environment || {}), this.stableStringify(nextEnvVars), ); this.pushChange( changes, 'volumes', this.stableStringify(serviceData.volumes || []), this.stableStringify(nextVolumes), ); this.pushChange( changes, 'publishedPorts', this.stableStringify(serviceData.publishedPorts || []), this.stableStringify(nextPublishedPorts), ); this.pushChange( changes, 'platformRequirements', this.stableStringify(await this.getServicePlatformRequirements(service.id)), this.stableStringify(config.platformRequirements || {}), ); return { serviceId: service.id, serviceName: serviceData.name, appTemplateId: serviceData.appTemplateId, fromVersion: serviceData.appTemplateVersion, targetVersion, resolvedTargetVersion, hasMigration, requiresManualReview, changes, warnings, blockers, config, appMeta, }; } public async applyUpgrade(serviceIdArg: string, targetVersionArg: string): Promise<{ service: Service; warnings: string[]; }> { const preview = await this.getAppStoreUpgradePreview(serviceIdArg, targetVersionArg); if (preview.blockers.length > 0) { throw new plugins.typedrequest.TypedResponseError(preview.blockers.join('; ')); } const service = await Service.getInstance({ id: serviceIdArg }); const serviceData = service.data as TExtendedServiceData; const envVars = this.getMergedUpgradeEnvVars(serviceData, preview.config, []); const oldWebPort = serviceData.ports?.web; const image = await this.upsertAppStoreImageForService( service, preview.config.image, preview.appMeta.description, preview.config.resolvedImageDigest, ); const webPort = preview.config.port; const nextDomains = (serviceData.domains || []).map((domainArg) => ({ ...domainArg, port: !domainArg.port || domainArg.port === oldWebPort ? webPort : domainArg.port, })); this.applyServiceDomainEnv(envVars, nextDomains[0]?.name); const nextVolumes = this.mergeUpgradeVolumes(serviceData.volumes || [], preview.config.volumes); const nextPublishedPorts = this.mergeUpgradePublishedPorts(serviceData.publishedPorts || [], preview.config.publishedPorts || []); service.data = { ...serviceData, description: serviceData.description || preview.appMeta.description, imageId: image.id, imageVersion: this.getImageTag(preview.config.image), appTemplateId: preview.appTemplateId, appTemplateVersion: preview.resolvedTargetVersion, environment: envVars, ports: { ...serviceData.ports, web: webPort, }, volumes: nextVolumes, publishedPorts: nextPublishedPorts, domains: nextDomains, } as plugins.servezoneInterfaces.data.IService['data']; await service.save(); await this.reconcilePlatformBindings(service, preview.config); await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); return { service, warnings: preview.warnings, }; } private async createUpgradeOperation( serviceIdArg: string, targetVersionArg: string, ): Promise { const existingRunning = this.getRunningUpgrade(serviceIdArg); if (existingRunning) { throw new plugins.typedrequest.TypedResponseError( `An upgrade is already running for ${existingRunning.serviceName}`, ); } const preview = await this.getAppStoreUpgradePreview(serviceIdArg, targetVersionArg); if (preview.blockers.length > 0) { throw new plugins.typedrequest.TypedResponseError(preview.blockers.join('; ')); } const now = Date.now(); const operation: IAppStoreUpgradeOperation = { id: plugins.smartunique.shortId(12), serviceId: preview.serviceId, serviceName: preview.serviceName, appTemplateId: preview.appTemplateId, fromVersion: preview.fromVersion, targetVersion: preview.resolvedTargetVersion, status: 'running', step: 'queued', progressLines: [`Queued upgrade ${preview.fromVersion} -> ${preview.resolvedTargetVersion}`], warnings: preview.warnings, startedAt: now, updatedAt: now, }; this.upgradeOperations.set(operation.id, operation); await this.pushUpgradeProgress(operation); return operation; } private getRunningUpgrade(serviceIdArg: string): IAppStoreUpgradeOperation | null { for (const operation of this.upgradeOperations.values()) { if (operation.serviceId === serviceIdArg && operation.status === 'running') { return operation; } } return null; } 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 operation: IAppStoreUpgradeOperation = { ...existing, ...updatesArg, step: stepArg, updatedAt: Date.now(), progressLines: [...existing.progressLines, messageArg].slice(-200), }; this.upgradeOperations.set(operationIdArg, operation); await this.pushUpgradeProgress(operation); return operation; } 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 preview = await this.getAppStoreUpgradePreview(operation.serviceId, operation.targetVersion); if (preview.blockers.length > 0) { throw new Error(preview.blockers.join('; ')); } await this.updateUpgradeOperation( operation.id, 'migration', preview.hasMigration ? 'Migration script detected; applying config-only upgrade' : 'No migration script detected', { warnings: preview.warnings }, ); await this.updateUpgradeOperation(operation.id, 'applying', `Applying upgrade to ${operation.serviceName}`); const { service, warnings } = await this.applyUpgrade(operation.serviceId, operation.targetVersion); await this.updateUpgradeOperation( operation.id, 'complete', `Upgrade completed for ${operation.serviceName}`, { status: 'success', completedAt: Date.now(), warnings, service: await service.createSavableObject(), }, ); return service; } catch (error) { await this.updateUpgradeOperation( operation.id, 'failed', `Upgrade failed: ${(error as Error).message}`, { status: 'failed', completedAt: Date.now(), error: (error as Error).message, }, ); throw error; } } public async installApp(optionsArg: IAppStoreInstallOptions): Promise { const appMeta = await this.getAppMeta(optionsArg.appId); const version = optionsArg.version || appMeta.latestVersion; const config = await this.getAppVersionConfig(optionsArg.appId, version); const appStoreVersion = config.appStoreVersion || version; const webPort = optionsArg.port || config.port; const publishedPorts = optionsArg.publishedPorts || config.publishedPorts || []; this.assertRuntimeCompatibility(config); this.assertSupportedPlatformRequirements(config); this.assertSupportedPublishedPorts(publishedPorts); const envVars = this.getAppStoreEnvVars(config, optionsArg.envVars || {}); if (this.requiresTemplateValue(envVars, 'SERVICE_DOMAIN') && !optionsArg.domain) { throw new Error('A domain is required because the app template uses ${SERVICE_DOMAIN}'); } this.applyServiceDomainEnv(envVars, optionsArg.domain); const image = await this.createAppStoreImage( optionsArg.serviceName, config.image, appMeta.description, config.resolvedImageDigest, ); const secretBundle = await this.createServiceSecretBundle(optionsArg.serviceName, image.id); const serviceData = { name: optionsArg.serviceName, description: appMeta.description, imageId: image.id, imageVersion: this.getImageTag(config.image), deployOnPush: false, appTemplateId: optionsArg.appId, appTemplateVersion: appStoreVersion, appStoreUpgradePolicy: 'manual', environment: envVars, secretBundleId: secretBundle.id, additionalSecretBundleIds: [], serviceCategory: 'workload', deploymentStrategy: 'limited-replicas', maxReplicas: 1, antiAffinity: false, scaleFactor: 1, balancingStrategy: 'round-robin', ports: { web: webPort }, volumes: this.normalizeVolumes(config.volumes), publishedPorts, domains: optionsArg.domain ? [{ name: optionsArg.domain, port: webPort, protocol: 'https' }] : [], deploymentIds: [], } as TExtendedServiceData; const service = await Service.createService(serviceData); secretBundle.data.serviceId = service.id; await secretBundle.save(); await this.reconcilePlatformBindings(service, config); await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(); return service; } private async createAppStoreImage( serviceNameArg: string, imageRefArg: string, descriptionArg: string, digestArg?: string, ): Promise { const image = new Image(); image.id = await Image.getNewId(); image.data = { name: `${serviceNameArg}-appstore-image`, description: descriptionArg, location: { internal: false, externalRegistryId: '', externalImageTag: imageRefArg, externalImageRef: imageRefArg, }, versions: [{ versionString: this.getImageTag(imageRefArg), digest: digestArg, source: 'registry', registryRepository: imageRefArg, registryTag: this.getImageTag(imageRefArg), size: 0, createdAt: Date.now(), }], }; await image.save(); return image; } private async upsertAppStoreImageForService( serviceArg: Service, imageRefArg: string, descriptionArg: string, digestArg?: string, ): Promise { let image: Image | undefined; try { image = await Image.getInstance({ id: serviceArg.data.imageId }); } catch {} if (!image) { return await this.createAppStoreImage(serviceArg.data.name, imageRefArg, descriptionArg, digestArg); } const imageTag = this.getImageTag(imageRefArg); image.data = { ...image.data, description: image.data.description || descriptionArg, location: { internal: false, externalRegistryId: '', externalImageTag: imageRefArg, externalImageRef: imageRefArg, }, versions: this.upsertImageVersion(image.data.versions || [], imageRefArg, imageTag, digestArg), }; await image.save(); return image; } private upsertImageVersion( versionsArg: plugins.servezoneInterfaces.data.IImage['data']['versions'], imageRefArg: string, imageTagArg: string, digestArg?: string, ): plugins.servezoneInterfaces.data.IImage['data']['versions'] { const nextVersions = [...versionsArg]; const existingIndex = nextVersions.findIndex((versionArg) => versionArg.versionString === imageTagArg); const versionData = { versionString: imageTagArg, digest: digestArg, source: 'registry' as const, registryRepository: imageRefArg, registryTag: imageTagArg, size: 0, createdAt: Date.now(), }; if (existingIndex >= 0) { nextVersions[existingIndex] = { ...nextVersions[existingIndex], ...versionData, createdAt: nextVersions[existingIndex].createdAt || versionData.createdAt, }; } else { nextVersions.push(versionData); } return nextVersions; } private async createServiceSecretBundle(serviceNameArg: string, imageIdArg: string): Promise { const secretBundle = new SecretBundle(); secretBundle.id = plugins.smartunique.shortId(8); secretBundle.data = { name: `${serviceNameArg} appstore secrets`, description: `Generated appstore secret bundle for ${serviceNameArg}`, type: 'service', includedSecretGroupIds: [], includedTags: [], imageClaims: [{ imageId: imageIdArg, permissions: ['read'] }], authorizations: [], }; await secretBundle.save(); return secretBundle; } private async reconcilePlatformBindings(serviceArg: Service, configArg: IAppStoreVersionConfig) { const requirements = configArg.platformRequirements || {}; await this.setPlatformBindingForCapability(serviceArg.id, 'database', Boolean(requirements.mongodb)); await this.setPlatformBindingForCapability(serviceArg.id, 'objectstorage', Boolean(requirements.s3)); } private async setPlatformBindingForCapability( serviceIdArg: string, capabilityArg: 'database' | 'objectstorage', enabledArg: boolean, ) { let existingBinding: PlatformBinding | undefined; try { existingBinding = await PlatformBinding.getInstance({ serviceId: serviceIdArg, capability: capabilityArg, }); } catch {} if (!enabledArg && !existingBinding) { return; } if (!enabledArg) { const bindingToDisable = existingBinding!; await PlatformBinding.upsertBinding({ id: bindingToDisable.id, serviceId: serviceIdArg, capability: capabilityArg, desiredState: 'disabled', status: 'disabled', providerConfigId: bindingToDisable.providerConfigId, config: bindingToDisable.config, endpoints: bindingToDisable.endpoints, credentials: bindingToDisable.credentials, createdAt: bindingToDisable.createdAt, }); return; } await this.upsertPlatformBindingForCapability(serviceIdArg, capabilityArg); } private async upsertPlatformBindingForCapability( serviceIdArg: string, capabilityArg: 'database' | 'objectstorage', ) { let existingBinding: PlatformBinding | undefined; try { existingBinding = await PlatformBinding.getInstance({ serviceId: serviceIdArg, capability: capabilityArg, }); } catch {} await PlatformBinding.upsertBinding({ id: existingBinding?.id || await PlatformBinding.getNewId(), serviceId: serviceIdArg, capability: capabilityArg, desiredState: 'enabled', status: existingBinding?.desiredState === 'disabled' ? 'requested' : existingBinding?.status || 'requested', providerConfigId: existingBinding?.providerConfigId, config: existingBinding?.config, endpoints: existingBinding?.endpoints, credentials: existingBinding?.credentials, createdAt: existingBinding?.createdAt, }); } private normalizeVolumes(volumesArg: IAppStoreVersionConfig['volumes'] = []) { return volumesArg.map((volumeArg) => { if (typeof volumeArg === 'string') { return { mountPath: volumeArg }; } return volumeArg; }).filter((volumeArg) => Boolean(volumeArg.mountPath)); } private mergeUpgradeVolumes( currentVolumesArg: TExtendedServiceData['volumes'] = [], templateVolumesArg: IAppStoreVersionConfig['volumes'] = [], ) { const templateVolumes = this.normalizeVolumes(templateVolumesArg); const currentVolumes = currentVolumesArg || []; const currentByMountPath = new Map(currentVolumes .filter((volumeArg) => Boolean(volumeArg.mountPath)) .map((volumeArg) => [volumeArg.mountPath, volumeArg])); const usedMountPaths = new Set(); const mergedVolumes = templateVolumes.map((templateVolumeArg) => { const currentVolume = currentByMountPath.get(templateVolumeArg.mountPath); usedMountPaths.add(templateVolumeArg.mountPath); return { ...templateVolumeArg, ...currentVolume, }; }); for (const currentVolume of currentVolumes) { if (!usedMountPaths.has(currentVolume.mountPath)) { mergedVolumes.push(currentVolume); } } return mergedVolumes; } private normalizePublishedPorts(publishedPortsArg: IAppStorePublishedPort[] = []): IAppStorePublishedPort[] { return publishedPortsArg.map((portArg) => ({ ...portArg, protocol: portArg.protocol || 'tcp', })); } private getPublishedPortTemplateKey(portArg: IAppStorePublishedPort): string { return [ portArg.protocol || 'tcp', portArg.targetPort, portArg.targetPortEnd || portArg.targetPort, ].join(':'); } private mergeUpgradePublishedPorts( currentPortsArg: IAppStorePublishedPort[] = [], templatePortsArg: IAppStorePublishedPort[] = [], ): IAppStorePublishedPort[] { const templatePorts = this.normalizePublishedPorts(templatePortsArg); const currentPorts = this.normalizePublishedPorts(currentPortsArg); if (templatePorts.length === 0) { return currentPorts; } const currentByTemplateKey = new Map(currentPorts.map((portArg) => [this.getPublishedPortTemplateKey(portArg), portArg])); const usedKeys = new Set(); const mergedPorts = templatePorts.map((templatePortArg) => { const key = this.getPublishedPortTemplateKey(templatePortArg); const currentPort = currentByTemplateKey.get(key); usedKeys.add(key); return { ...templatePortArg, ...currentPort, }; }); for (const currentPort of currentPorts) { const key = this.getPublishedPortTemplateKey(currentPort); if (!usedKeys.has(key)) { mergedPorts.push(currentPort); } } return mergedPorts; } private getAppStoreEnvVars(configArg: IAppStoreVersionConfig, overridesArg: Record): Record { const envVars: Record = {}; const missingRequiredEnvVars: string[] = []; for (const envVar of configArg.envVars || []) { const value = overridesArg[envVar.key] ?? envVar.value ?? ''; if (envVar.required && !value) { missingRequiredEnvVars.push(envVar.key); } envVars[envVar.key] = value; } Object.assign(envVars, overridesArg); if (missingRequiredEnvVars.length > 0) { throw new Error(`Missing required app env var(s): ${missingRequiredEnvVars.join(', ')}`); } return envVars; } private requiresTemplateValue(envVarsArg: Record, templateNameArg: string): boolean { return Object.values(envVarsArg).some((value) => value.includes(`\${${templateNameArg}}`)); } private applyServiceDomainEnv(envVarsArg: Record, serviceDomainArg?: string) { if (serviceDomainArg && this.requiresTemplateValue(envVarsArg, 'SERVICE_DOMAIN')) { envVarsArg.SERVICE_DOMAIN = serviceDomainArg; } } private assertSupportedPlatformRequirements(configArg: IAppStoreVersionConfig) { const unsupported = this.getUnsupportedPlatformRequirements(configArg); if (unsupported.length > 0) { throw new Error(`Cloudly App Store install does not yet support platform requirement(s): ${unsupported.join(', ')}`); } } private getUnsupportedPlatformRequirements(configArg: IAppStoreVersionConfig) { return Object.entries(configArg.platformRequirements || {}) .filter(([key, enabled]) => enabled && key !== 'mongodb' && key !== 's3') .map(([key]) => key); } private assertSupportedPublishedPorts(publishedPortsArg: IAppStoreVersionConfig['publishedPorts'] = []) { const unsupported = this.getUnsupportedPublishedPorts(publishedPortsArg); if (unsupported.length > 0) { throw new Error(`Cloudly cannot apply published port setting(s): ${unsupported.join(', ')}`); } } private getUnsupportedPublishedPorts(publishedPortsArg: IAppStoreVersionConfig['publishedPorts'] = []) { const unsupported: string[] = []; const seenPublishedPorts = new Set(); for (const portArg of publishedPortsArg) { const protocol = portArg.protocol || 'tcp'; const targetStart = portArg.targetPort; const targetEnd = portArg.targetPortEnd || targetStart; const publishedStart = portArg.publishedPort || targetStart; const publishedEnd = portArg.publishedPortEnd || (publishedStart + (targetEnd - targetStart)); const description = this.formatPublishedPortDescription(portArg); if (portArg.hostIp && portArg.hostIp !== '0.0.0.0') { unsupported.push(`${description} uses unsupported hostIp ${portArg.hostIp}`); } for (const [label, value] of Object.entries({ targetStart, targetEnd, publishedStart, publishedEnd })) { if (!Number.isInteger(value) || value < 1 || value > 65535) { unsupported.push(`${description} has invalid ${label} ${value}`); } } if (targetEnd < targetStart) { unsupported.push(`${description} has targetPortEnd before targetPort`); } if (publishedEnd < publishedStart) { unsupported.push(`${description} has publishedPortEnd before publishedPort`); } if ((targetEnd - targetStart) !== (publishedEnd - publishedStart)) { unsupported.push(`${description} has mismatched target and published port ranges`); } if (targetEnd >= targetStart && publishedEnd >= publishedStart && (targetEnd - targetStart) === (publishedEnd - publishedStart)) { for (let offset = 0; offset <= targetEnd - targetStart; offset++) { const publishedKey = `${protocol}:${publishedStart + offset}`; if (seenPublishedPorts.has(publishedKey)) { unsupported.push(`${description} duplicates published port ${publishedStart + offset}/${protocol}`); } seenPublishedPorts.add(publishedKey); } } } return unsupported; } private formatPublishedPortDescription(portArg: IAppStorePublishedPort) { const protocol = portArg.protocol || 'tcp'; const target = portArg.targetPortEnd ? `${portArg.targetPort}-${portArg.targetPortEnd}` : String(portArg.targetPort); const publishedStart = portArg.publishedPort || portArg.targetPort; const publishedEnd = portArg.publishedPortEnd || (portArg.targetPortEnd ? publishedStart + (portArg.targetPortEnd - portArg.targetPort) : undefined); const published = publishedEnd ? `${publishedStart}-${publishedEnd}` : String(publishedStart); return `${published}/${protocol} -> ${target}/${protocol}`; } private assertRuntimeCompatibility(configArg: IAppStoreVersionConfig) { if (configArg.minCloudlyVersion && this.compareVersions(commitinfo.version, configArg.minCloudlyVersion) < 0) { throw new Error(`App requires Cloudly >= ${configArg.minCloudlyVersion}; current version is ${commitinfo.version}`); } } private compareVersions(versionAArg: string, versionBArg: string): number { const normalize = (versionArg: string) => versionArg.replace(/^v/, '').split('.').map((partArg) => Number(partArg) || 0); const versionA = normalize(versionAArg); const versionB = normalize(versionBArg); for (let i = 0; i < Math.max(versionA.length, versionB.length); i++) { const diff = (versionA[i] || 0) - (versionB[i] || 0); if (diff !== 0) { return diff > 0 ? 1 : -1; } } return 0; } private async hasMigrationScript(appIdArg: string, fromVersionArg: string, toVersionArg: string): Promise { const path = `apps/${appIdArg}/versions/${toVersionArg}/migrate-from-${fromVersionArg}.ts`; const baseUrl = this.appStoreResolver.baseUrl.replace(/\/+$/, ''); const response = await fetch(`${baseUrl}/${path}`); if (response.status === 404) { return false; } if (!response.ok) { throw new Error(`Could not check App Store migration script: HTTP ${response.status} for ${path}`); } return true; } private getMergedUpgradeEnvVars( serviceDataArg: TExtendedServiceData, configArg: IAppStoreVersionConfig, blockersArg: string[], ): Record { const envVars: Record = { ...(serviceDataArg.environment || {}), }; for (const envVar of configArg.envVars || []) { const value = envVars[envVar.key] ?? envVar.value ?? ''; if (envVar.required && !value) { blockersArg.push(`Missing required app env var: ${envVar.key}`); } envVars[envVar.key] = value; } return envVars; } private async getServiceImageReference(serviceArg: Service): Promise { try { const image = await Image.getInstance({ id: serviceArg.data.imageId }); return image?.data.location?.externalImageRef || image?.data.location?.externalImageTag || `${serviceArg.data.imageId}:${serviceArg.data.imageVersion}`; } catch { return `${serviceArg.data.imageId}:${serviceArg.data.imageVersion}`; } } private async getServicePlatformRequirements(serviceIdArg: string) { const bindings = await PlatformBinding.getInstances({ serviceId: serviceIdArg }); return { mongodb: bindings.some((bindingArg) => bindingArg.capability === 'database' && bindingArg.desiredState !== 'disabled'), s3: bindings.some((bindingArg) => bindingArg.capability === 'objectstorage' && bindingArg.desiredState !== 'disabled'), }; } private pushChange( changesArg: IAppStoreUpgradeChange[], fieldArg: string, currentValueArg: string, targetValueArg: string, ) { if (currentValueArg === targetValueArg) { return; } changesArg.push({ field: fieldArg, currentValue: currentValueArg, targetValue: targetValueArg, }); } private stableStringify(valueArg: unknown): string { if (Array.isArray(valueArg)) { return `[${valueArg.map((itemArg) => this.stableStringify(itemArg)).join(',')}]`; } if (valueArg && typeof valueArg === 'object') { return `{${Object.keys(valueArg as Record) .sort() .map((keyArg) => `${JSON.stringify(keyArg)}:${this.stableStringify((valueArg as Record)[keyArg])}`) .join(',')}}`; } return JSON.stringify(valueArg); } private async pushUpgradeProgress(operationArg: IAppStoreUpgradeOperation): Promise { const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket; if (!typedsocket) { return; } const connections = await typedsocket.findAllTargetConnections(async (connectionArg) => { const identityTag = await connectionArg.getTagById('identity'); const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined; return identity?.role === 'admin'; }); await Promise.allSettled( connections.map((connectionArg) => typedsocket .createTypedRequest('pushAppStoreUpgradeProgress', connectionArg) .fire({ operation: operationArg })), ); } private getImageTag(imageRefArg: string) { const lastSlashIndex = imageRefArg.lastIndexOf('/'); const lastColonIndex = imageRefArg.lastIndexOf(':'); return lastColonIndex > lastSlashIndex ? imageRefArg.slice(lastColonIndex + 1) || 'latest' : 'latest'; } private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) { await plugins.smartguard.passGuardsOrReject(dataArg, [ this.cloudlyRef.authManager.adminIdentityGuard, ]); } }